@usequark/quark-core 2.5.4

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.
Files changed (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -0,0 +1,365 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { jobDuration, jobQueueDepth, jobsProcessedTotal } from "../metrics.js";
4
+ import {
5
+ addJob,
6
+ checkQueueHealth,
7
+ createQueue,
8
+ createWorker,
9
+ getRegisteredQueues,
10
+ updateQueueDepths,
11
+ } from "./index.js";
12
+
13
+ // ─── getRegisteredQueues ──────────────────────────────────────────────────────
14
+
15
+ describe("getRegisteredQueues", () => {
16
+ let q1;
17
+ let q2;
18
+
19
+ after(async () => {
20
+ await q1?.close().catch(() => {});
21
+ await q2?.close().catch(() => {});
22
+ });
23
+
24
+ test("returns the internal queues Map", () => {
25
+ const result = getRegisteredQueues();
26
+ assert.ok(result instanceof Map, "should be a Map");
27
+ });
28
+
29
+ test("reflects queues registered via createQueue", () => {
30
+ const before = getRegisteredQueues().size;
31
+ q1 = createQueue("test-registry-queue");
32
+ assert.strictEqual(getRegisteredQueues().size, before + 1);
33
+ assert.ok(getRegisteredQueues().has("test-registry-queue"));
34
+ });
35
+
36
+ test("createQueue is idempotent - same instance on repeated calls", () => {
37
+ q2 = createQueue("test-idempotent-queue");
38
+ const again = createQueue("test-idempotent-queue");
39
+ assert.strictEqual(q2, again, "should return the same queue instance");
40
+ });
41
+ });
42
+
43
+ // ─── createQueue close safety ────────────────────────────────────────────────
44
+ // Regression: a closed queue used to stay in the singleton registry, so every
45
+ // later createQueue(name) handed back the same poisoned, unusable instance.
46
+ // Required behavior: close → evict → recreate → usable.
47
+
48
+ describe("createQueue - close safety", () => {
49
+ const NAME = "test-close-recreate-queue";
50
+ let queue;
51
+
52
+ after(async () => {
53
+ await queue?.close().catch(() => {});
54
+ });
55
+
56
+ test("closing a queue evicts it from the registry", async () => {
57
+ queue = createQueue(NAME);
58
+ assert.ok(
59
+ getRegisteredQueues().has(NAME),
60
+ "queue should be registered while open",
61
+ );
62
+
63
+ await queue.close();
64
+
65
+ assert.ok(
66
+ !getRegisteredQueues().has(NAME),
67
+ "closed queue should be evicted from the registry",
68
+ );
69
+ });
70
+
71
+ test("createQueue after close returns a fresh, usable queue", async () => {
72
+ const first = queue; // the closed instance from the previous test
73
+
74
+ queue = createQueue(NAME);
75
+
76
+ assert.notStrictEqual(
77
+ queue,
78
+ first,
79
+ "should build a new instance instead of returning the closed one",
80
+ );
81
+ assert.ok(
82
+ getRegisteredQueues().has(NAME),
83
+ "new instance should be registered",
84
+ );
85
+
86
+ // "Usable" means it can actually reach Redis and enqueue a job.
87
+ // Use the public readiness primitive - `client` is not stable across
88
+ // BullMQ majors and was removed in v6.
89
+ await queue.waitUntilReady();
90
+ const job = await queue.add("close-recreate-probe", { probe: true });
91
+ assert.ok(job.id, "fresh queue should enqueue a job");
92
+
93
+ await queue.obliterate({ force: true }).catch(() => {});
94
+ });
95
+
96
+ test("a queue closed outside the wrapper is still replaced", async () => {
97
+ const stale = queue;
98
+ // Bypass our evict-on-close wrapper and close via the prototype directly
99
+ await Object.getPrototypeOf(stale).close.call(stale);
100
+
101
+ queue = createQueue(NAME);
102
+
103
+ assert.notStrictEqual(
104
+ queue,
105
+ stale,
106
+ "a known-closed instance must never be handed out again",
107
+ );
108
+ assert.ok(getRegisteredQueues().has(NAME));
109
+ });
110
+ });
111
+
112
+ // ─── addJob deduplication ─────────────────────────────────────────────────────
113
+ // Regression: dedup was hand-rolled with a raw `SET NX` against `queue.client`.
114
+ // BullMQ 6 removed that getter, so the `SET NX` threw, the best-effort `catch`
115
+ // swallowed it, and every "deduplicated" job was enqueued anyway - silently.
116
+ // These tests pin that dedup works through the public BullMQ option and that
117
+ // we never reach for BullMQ internals to do it.
118
+
119
+ describe("addJob - deduplication", () => {
120
+ const NAME = "test-dedup-queue";
121
+ let queue;
122
+
123
+ before(() => {
124
+ queue = createQueue(NAME);
125
+ });
126
+
127
+ after(async () => {
128
+ await queue?.obliterate({ force: true }).catch(() => {});
129
+ await queue?.close().catch(() => {});
130
+ });
131
+
132
+ test("a repeated dedupKey does not enqueue a second job", async () => {
133
+ const first = await addJob(
134
+ queue,
135
+ "dedup-probe",
136
+ { n: 1 },
137
+ {
138
+ dedupKey: "probe-key",
139
+ dedupTTL: 60,
140
+ },
141
+ );
142
+ assert.ok(first?.id, "the first add should enqueue");
143
+
144
+ // BullMQ throttles the duplicate instead of creating a second job.
145
+ const second = await addJob(
146
+ queue,
147
+ "dedup-probe",
148
+ { n: 2 },
149
+ {
150
+ dedupKey: "probe-key",
151
+ dedupTTL: 60,
152
+ },
153
+ );
154
+ assert.strictEqual(
155
+ second?.id,
156
+ first.id,
157
+ "a duplicate dedupKey must resolve to the already-queued job",
158
+ );
159
+
160
+ const waiting = await queue.getWaitingCount();
161
+ assert.strictEqual(waiting, 1, "only one job should be waiting");
162
+ });
163
+
164
+ test("different dedupKeys enqueue independently", async () => {
165
+ const a = await addJob(
166
+ queue,
167
+ "dedup-probe-b",
168
+ { n: 1 },
169
+ {
170
+ dedupKey: "probe-a",
171
+ dedupTTL: 60,
172
+ },
173
+ );
174
+ const b = await addJob(
175
+ queue,
176
+ "dedup-probe-b",
177
+ { n: 1 },
178
+ {
179
+ dedupKey: "probe-b",
180
+ dedupTTL: 60,
181
+ },
182
+ );
183
+ assert.notStrictEqual(
184
+ a.id,
185
+ b.id,
186
+ "distinct keys must produce distinct jobs",
187
+ );
188
+ });
189
+
190
+ test("no dedupKey means no deduplication", async () => {
191
+ const a = await addJob(queue, "dedup-probe-none", { n: 1 });
192
+ const b = await addJob(queue, "dedup-probe-none", { n: 1 });
193
+ assert.notStrictEqual(a.id, b.id, "jobs without a dedupKey always enqueue");
194
+ });
195
+ });
196
+
197
+ // ─── checkQueueHealth ─────────────────────────────────────────────────────────
198
+ // Uses the public `waitUntilReady()`; a healthy Redis must report healthy, not
199
+ // "unavailable" (which is what happened once BullMQ removed `queue.client`).
200
+
201
+ describe("checkQueueHealth", () => {
202
+ test("reports healthy against a live Redis", async () => {
203
+ assert.strictEqual(await checkQueueHealth(), true);
204
+ });
205
+ });
206
+
207
+ // ─── updateQueueDepths ────────────────────────────────────────────────────────
208
+
209
+ describe("updateQueueDepths", () => {
210
+ test("resolves without error when no queues are registered", async () => {
211
+ // Create a fresh scenario - if no queues in the singleton map, just skip
212
+ // We can't easily reset the singleton, so we just verify it doesn't throw
213
+ await assert.doesNotReject(() => updateQueueDepths());
214
+ });
215
+ });
216
+
217
+ // ─── createWorker auto-instrumentation ───────────────────────────────────────
218
+ // These tests require a live Redis connection.
219
+
220
+ describe("createWorker - metrics instrumentation", () => {
221
+ const TEST_QUEUE = "test-metrics-queue";
222
+ let queue;
223
+
224
+ before(async () => {
225
+ queue = createQueue(TEST_QUEUE);
226
+ });
227
+
228
+ after(async () => {
229
+ await queue?.close().catch(() => {});
230
+ });
231
+
232
+ test("jobsProcessedTotal increments on job completion", async () => {
233
+ const before =
234
+ jobsProcessedTotal.values.get(
235
+ `{queue="${TEST_QUEUE}",status="completed"}`,
236
+ ) ?? 0;
237
+
238
+ const worker = createWorker(TEST_QUEUE, async () => ({ ok: true }), {
239
+ concurrency: 1,
240
+ });
241
+
242
+ try {
243
+ // Add a job and wait for it to complete
244
+ await new Promise((resolve, reject) => {
245
+ const timeout = setTimeout(
246
+ () => reject(new Error("Job did not complete in time")),
247
+ 5000,
248
+ );
249
+ worker.once("completed", () => {
250
+ clearTimeout(timeout);
251
+ resolve();
252
+ });
253
+ worker.once("error", (err) => {
254
+ clearTimeout(timeout);
255
+ reject(err);
256
+ });
257
+ queue.add("test-job", { test: true }).catch(reject);
258
+ });
259
+
260
+ const after =
261
+ jobsProcessedTotal.values.get(
262
+ `{queue="${TEST_QUEUE}",status="completed"}`,
263
+ ) ?? 0;
264
+ assert.ok(after > before, "completed counter should have incremented");
265
+ } finally {
266
+ await worker.close().catch(() => {});
267
+ }
268
+ });
269
+
270
+ test("jobsProcessedTotal increments on job failure", async () => {
271
+ const before =
272
+ jobsProcessedTotal.values.get(
273
+ `{queue="${TEST_QUEUE}",status="failed"}`,
274
+ ) ?? 0;
275
+
276
+ const worker = createWorker(
277
+ TEST_QUEUE,
278
+ async () => {
279
+ throw new Error("intentional failure");
280
+ },
281
+ { concurrency: 1 },
282
+ );
283
+
284
+ try {
285
+ await new Promise((resolve, reject) => {
286
+ const timeout = setTimeout(
287
+ () => reject(new Error("Job did not fail in time")),
288
+ 5000,
289
+ );
290
+ worker.once("failed", () => {
291
+ clearTimeout(timeout);
292
+ resolve();
293
+ });
294
+ worker.once("error", (err) => {
295
+ clearTimeout(timeout);
296
+ reject(err);
297
+ });
298
+ // attempts:1 so it fails immediately without retries
299
+ queue
300
+ .add("test-fail-job", { test: true }, { attempts: 1 })
301
+ .catch(reject);
302
+ });
303
+
304
+ const after =
305
+ jobsProcessedTotal.values.get(
306
+ `{queue="${TEST_QUEUE}",status="failed"}`,
307
+ ) ?? 0;
308
+ assert.ok(after > before, "failed counter should have incremented");
309
+ } finally {
310
+ await worker.close().catch(() => {});
311
+ }
312
+ });
313
+
314
+ test("jobDuration records a histogram observation on completion", async () => {
315
+ const beforeCount =
316
+ jobDuration.values.get(`{name="timed-job",queue="${TEST_QUEUE}"}`)
317
+ ?.count ?? 0;
318
+
319
+ const worker = createWorker(TEST_QUEUE, async () => ({ ok: true }), {
320
+ concurrency: 1,
321
+ });
322
+
323
+ try {
324
+ await new Promise((resolve, reject) => {
325
+ const timeout = setTimeout(
326
+ () => reject(new Error("Timed job did not complete")),
327
+ 5000,
328
+ );
329
+ worker.once("completed", () => {
330
+ clearTimeout(timeout);
331
+ resolve();
332
+ });
333
+ worker.once("error", (err) => {
334
+ clearTimeout(timeout);
335
+ reject(err);
336
+ });
337
+ queue.add("timed-job", { test: true }).catch(reject);
338
+ });
339
+
340
+ const afterCount =
341
+ jobDuration.values.get(`{name="timed-job",queue="${TEST_QUEUE}"}`)
342
+ ?.count ?? 0;
343
+ assert.ok(afterCount > beforeCount, "histogram count should have grown");
344
+ } finally {
345
+ await worker.close().catch(() => {});
346
+ }
347
+ });
348
+
349
+ test("updateQueueDepths sets job_queue_depth gauge", async () => {
350
+ // Add a few jobs to create a backlog, then update depths
351
+ await queue.addBulk([
352
+ { name: "depth-test", data: {} },
353
+ { name: "depth-test", data: {} },
354
+ ]);
355
+
356
+ await updateQueueDepths();
357
+
358
+ const depth = jobQueueDepth.values.get(`{queue="${TEST_QUEUE}"}`) ?? 0;
359
+ // We added 2 jobs; depth should be >= 2 (could be higher if prior test jobs remain)
360
+ assert.ok(depth >= 2, `expected depth >= 2, got ${depth}`);
361
+
362
+ // Drain queue so it doesn't interfere with other tests
363
+ await queue.obliterate({ force: true }).catch(() => {});
364
+ });
365
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * @techstream/quark-core - Rate Limiting Module
3
+ * Provides both in-memory and Redis-based rate limiting
4
+ */
5
+
6
+ /**
7
+ * In-memory rate limiter (for single-instance deployments)
8
+ */
9
+ class MemoryRateLimiter {
10
+ constructor() {
11
+ this.store = new Map();
12
+ this.cleanupInterval = null;
13
+ }
14
+
15
+ /**
16
+ * Start periodic cleanup of expired records
17
+ */
18
+ startCleanup(intervalMs = 60000) {
19
+ if (this.cleanupInterval) return;
20
+
21
+ this.cleanupInterval = setInterval(() => {
22
+ const now = Date.now();
23
+ for (const [key, record] of this.store.entries()) {
24
+ if (now > record.resetTime) {
25
+ this.store.delete(key);
26
+ }
27
+ }
28
+ }, intervalMs);
29
+
30
+ // Don't keep the process alive just for cleanup
31
+ this.cleanupInterval.unref();
32
+ }
33
+
34
+ /**
35
+ * Stop cleanup interval
36
+ */
37
+ stopCleanup() {
38
+ if (this.cleanupInterval) {
39
+ clearInterval(this.cleanupInterval);
40
+ this.cleanupInterval = null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Check and increment rate limit
46
+ */
47
+ async checkLimit(key, maxRequests, windowMs) {
48
+ const now = Date.now();
49
+ const record = this.store.get(key) || {
50
+ count: 0,
51
+ resetTime: now + windowMs,
52
+ };
53
+
54
+ // Reset if window has passed
55
+ if (now > record.resetTime) {
56
+ record.count = 0;
57
+ record.resetTime = now + windowMs;
58
+ }
59
+
60
+ // Check if limit exceeded
61
+ if (record.count >= maxRequests) {
62
+ return {
63
+ limited: true,
64
+ remaining: 0,
65
+ resetTime: record.resetTime,
66
+ };
67
+ }
68
+
69
+ // Increment counter
70
+ record.count++;
71
+ this.store.set(key, record);
72
+
73
+ return {
74
+ limited: false,
75
+ remaining: maxRequests - record.count,
76
+ resetTime: record.resetTime,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Reset a specific key
82
+ */
83
+ async reset(key) {
84
+ this.store.delete(key);
85
+ }
86
+
87
+ /**
88
+ * Clear all records
89
+ */
90
+ async clear() {
91
+ this.store.clear();
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Redis-based rate limiter (for multi-instance deployments)
97
+ * Uses Lua scripting for atomic check-and-increment.
98
+ */
99
+ class RedisRateLimiter {
100
+ constructor(redisClient, options = {}) {
101
+ this.redis = redisClient;
102
+ this.failOpen = options.failOpen ?? true;
103
+ }
104
+
105
+ /**
106
+ * Check and increment rate limit using an atomic Lua script.
107
+ * Prevents TOCTOU race conditions by doing INCR + PEXPIRE in one round-trip.
108
+ */
109
+ async checkLimit(key, maxRequests, windowMs) {
110
+ const now = Date.now();
111
+ const windowKey = `ratelimit:${key}`;
112
+
113
+ try {
114
+ // Atomic Lua: INCR the key, set PEXPIRE on first request, return [count, pttl]
115
+ const luaScript = `
116
+ local count = redis.call('INCR', KEYS[1])
117
+ if count == 1 then
118
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
119
+ end
120
+ local ttl = redis.call('PTTL', KEYS[1])
121
+ return {count, ttl}
122
+ `;
123
+
124
+ const [currentCount, ttl] = await this.redis.eval(
125
+ luaScript,
126
+ 1,
127
+ windowKey,
128
+ windowMs,
129
+ );
130
+
131
+ const resetTime = ttl > 0 ? now + ttl : now + windowMs;
132
+
133
+ if (currentCount > maxRequests) {
134
+ return {
135
+ limited: true,
136
+ remaining: 0,
137
+ resetTime,
138
+ };
139
+ }
140
+
141
+ return {
142
+ limited: false,
143
+ remaining: maxRequests - currentCount,
144
+ resetTime,
145
+ };
146
+ } catch (_error) {
147
+ // Configurable fail-open / fail-closed behaviour
148
+ if (this.failOpen) {
149
+ return {
150
+ limited: false,
151
+ remaining: maxRequests,
152
+ resetTime: now + windowMs,
153
+ };
154
+ }
155
+ return {
156
+ limited: true,
157
+ remaining: 0,
158
+ resetTime: now + windowMs,
159
+ };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Reset a specific key
165
+ */
166
+ async reset(key) {
167
+ await this.redis.del(`ratelimit:${key}`);
168
+ }
169
+
170
+ /**
171
+ * Clear all rate limit keys using SCAN (non-blocking, production-safe)
172
+ */
173
+ async clear() {
174
+ let cursor = "0";
175
+ do {
176
+ const [nextCursor, keys] = await this.redis.scan(
177
+ cursor,
178
+ "MATCH",
179
+ "ratelimit:*",
180
+ "COUNT",
181
+ 100,
182
+ );
183
+ cursor = nextCursor;
184
+ if (keys.length > 0) {
185
+ await this.redis.del(...keys);
186
+ }
187
+ } while (cursor !== "0");
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Create a rate limiter instance
193
+ * @param {Object} options - Configuration options
194
+ * @param {string} options.type - "memory" or "redis"
195
+ * @param {Object} options.redisClient - Redis client instance (required if type is "redis")
196
+ * @returns {MemoryRateLimiter|RedisRateLimiter}
197
+ */
198
+ export function createRateLimiter(options = {}) {
199
+ const { type = "memory", redisClient = null } = options;
200
+
201
+ if (type === "redis") {
202
+ if (!redisClient) {
203
+ throw new Error("Redis client is required for Redis-based rate limiting");
204
+ }
205
+ return new RedisRateLimiter(redisClient, { failOpen: options.failOpen });
206
+ }
207
+
208
+ const limiter = new MemoryRateLimiter();
209
+ limiter.startCleanup();
210
+ return limiter;
211
+ }
212
+
213
+ /**
214
+ * Rate limiter configuration
215
+ */
216
+ export const RATE_LIMIT_PRESETS = {
217
+ strict: {
218
+ windowMs: 15 * 60 * 1000, // 15 minutes
219
+ maxRequests: 5,
220
+ },
221
+ moderate: {
222
+ windowMs: 15 * 60 * 1000, // 15 minutes
223
+ maxRequests: 50,
224
+ },
225
+ relaxed: {
226
+ windowMs: 15 * 60 * 1000, // 15 minutes
227
+ maxRequests: 100,
228
+ },
229
+ auth: {
230
+ windowMs: 15 * 60 * 1000, // 15 minutes
231
+ maxRequests: 5,
232
+ },
233
+ api: {
234
+ windowMs: 15 * 60 * 1000, // 15 minutes
235
+ maxRequests: 100,
236
+ },
237
+ };
238
+
239
+ /**
240
+ * Helper function to create rate limit middleware
241
+ * @param {Object} limiter - Rate limiter instance
242
+ * @param {Object} config - Rate limit configuration
243
+ * @returns {Function} Middleware function
244
+ */
245
+ export function createRateLimitMiddleware(
246
+ limiter,
247
+ config = RATE_LIMIT_PRESETS.api,
248
+ ) {
249
+ return async (ip, path) => {
250
+ const key = `${ip}:${path}`;
251
+ return limiter.checkLimit(key, config.maxRequests, config.windowMs);
252
+ };
253
+ }