@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.
- package/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createRateLimiter,
|
|
5
|
+
createRateLimitMiddleware,
|
|
6
|
+
RATE_LIMIT_PRESETS,
|
|
7
|
+
} from "../src/rate-limiter.js";
|
|
8
|
+
|
|
9
|
+
test("Rate Limiter Module", async (t) => {
|
|
10
|
+
await t.test("createRateLimiter creates memory limiter by default", () => {
|
|
11
|
+
const limiter = createRateLimiter();
|
|
12
|
+
assert(limiter !== null);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
await t.test(
|
|
16
|
+
"createRateLimiter throws error for Redis without client",
|
|
17
|
+
() => {
|
|
18
|
+
assert.throws(
|
|
19
|
+
() => createRateLimiter({ type: "redis" }),
|
|
20
|
+
/Redis client is required/,
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
await t.test("Memory rate limiter allows requests under limit", async () => {
|
|
26
|
+
const limiter = createRateLimiter({ type: "memory" });
|
|
27
|
+
const result = await limiter.checkLimit("test-key", 5, 60000);
|
|
28
|
+
|
|
29
|
+
assert.strictEqual(result.limited, false);
|
|
30
|
+
assert.strictEqual(result.remaining, 4);
|
|
31
|
+
assert(result.resetTime > Date.now());
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await t.test("Memory rate limiter blocks requests over limit", async () => {
|
|
35
|
+
const limiter = createRateLimiter({ type: "memory" });
|
|
36
|
+
|
|
37
|
+
// Make 5 requests (limit is 5)
|
|
38
|
+
for (let i = 0; i < 5; i++) {
|
|
39
|
+
await limiter.checkLimit("test-key-2", 5, 60000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 6th request should be blocked
|
|
43
|
+
const result = await limiter.checkLimit("test-key-2", 5, 60000);
|
|
44
|
+
assert.strictEqual(result.limited, true);
|
|
45
|
+
assert.strictEqual(result.remaining, 0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await t.test("Memory rate limiter resets after window", async () => {
|
|
49
|
+
const limiter = createRateLimiter({ type: "memory" });
|
|
50
|
+
|
|
51
|
+
// Make a request with very short window
|
|
52
|
+
const result1 = await limiter.checkLimit("test-key-3", 1, 10);
|
|
53
|
+
assert.strictEqual(result1.limited, false);
|
|
54
|
+
|
|
55
|
+
// Second request should be blocked
|
|
56
|
+
const result2 = await limiter.checkLimit("test-key-3", 1, 10);
|
|
57
|
+
assert.strictEqual(result2.limited, true);
|
|
58
|
+
|
|
59
|
+
// Wait for window to expire
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
61
|
+
|
|
62
|
+
// Should be allowed again
|
|
63
|
+
const result3 = await limiter.checkLimit("test-key-3", 1, 10);
|
|
64
|
+
assert.strictEqual(result3.limited, false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await t.test("Memory rate limiter can reset a key", async () => {
|
|
68
|
+
const limiter = createRateLimiter({ type: "memory" });
|
|
69
|
+
|
|
70
|
+
await limiter.checkLimit("test-key-4", 1, 60000);
|
|
71
|
+
await limiter.reset("test-key-4");
|
|
72
|
+
|
|
73
|
+
const result = await limiter.checkLimit("test-key-4", 1, 60000);
|
|
74
|
+
assert.strictEqual(result.limited, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await t.test("Memory rate limiter can clear all keys", async () => {
|
|
78
|
+
const limiter = createRateLimiter({ type: "memory" });
|
|
79
|
+
|
|
80
|
+
await limiter.checkLimit("key-1", 1, 60000);
|
|
81
|
+
await limiter.checkLimit("key-2", 1, 60000);
|
|
82
|
+
await limiter.clear();
|
|
83
|
+
|
|
84
|
+
const result1 = await limiter.checkLimit("key-1", 1, 60000);
|
|
85
|
+
const result2 = await limiter.checkLimit("key-2", 1, 60000);
|
|
86
|
+
|
|
87
|
+
assert.strictEqual(result1.limited, false);
|
|
88
|
+
assert.strictEqual(result2.limited, false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await t.test("RATE_LIMIT_PRESETS have correct structure", () => {
|
|
92
|
+
assert(RATE_LIMIT_PRESETS.strict);
|
|
93
|
+
assert(RATE_LIMIT_PRESETS.moderate);
|
|
94
|
+
assert(RATE_LIMIT_PRESETS.relaxed);
|
|
95
|
+
assert(RATE_LIMIT_PRESETS.auth);
|
|
96
|
+
assert(RATE_LIMIT_PRESETS.api);
|
|
97
|
+
|
|
98
|
+
assert.strictEqual(RATE_LIMIT_PRESETS.auth.maxRequests, 5);
|
|
99
|
+
assert.strictEqual(RATE_LIMIT_PRESETS.api.maxRequests, 100);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await t.test("createRateLimitMiddleware returns a function", () => {
|
|
103
|
+
const limiter = createRateLimiter({ type: "memory" });
|
|
104
|
+
const middleware = createRateLimitMiddleware(limiter);
|
|
105
|
+
|
|
106
|
+
assert.strictEqual(typeof middleware, "function");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await t.test("createRateLimitMiddleware works with API preset", async () => {
|
|
110
|
+
const limiter = createRateLimiter({ type: "memory" });
|
|
111
|
+
const middleware = createRateLimitMiddleware(
|
|
112
|
+
limiter,
|
|
113
|
+
RATE_LIMIT_PRESETS.api,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const result = await middleware("192.168.1.1", "/api/posts");
|
|
117
|
+
|
|
118
|
+
assert.strictEqual(result.limited, false);
|
|
119
|
+
assert(result.remaining > 0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await t.test("Cleanup interval is started for memory limiter", () => {
|
|
123
|
+
const limiter = createRateLimiter({ type: "memory" });
|
|
124
|
+
assert(limiter.cleanupInterval !== null);
|
|
125
|
+
|
|
126
|
+
// Clean up
|
|
127
|
+
limiter.stopCleanup();
|
|
128
|
+
assert(limiter.cleanupInterval === null);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/src/redis.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds REDIS_URL from individual environment variables if not explicitly provided.
|
|
3
|
+
* Allows configuration via individual REDIS_* vars instead of a single REDIS_URL.
|
|
4
|
+
*/
|
|
5
|
+
function getRedisUrl() {
|
|
6
|
+
if (process.env.REDIS_URL) {
|
|
7
|
+
return process.env.REDIS_URL;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const host = process.env.REDIS_HOST || "localhost";
|
|
11
|
+
const port = process.env.REDIS_PORT || "6379";
|
|
12
|
+
|
|
13
|
+
return `redis://${host}:${port}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates a Redis configuration object from environment variables.
|
|
18
|
+
* Returns the URL and any additional options - does not create an actual connection.
|
|
19
|
+
* Pass the URL to your Redis library of choice (e.g., ioredis).
|
|
20
|
+
*
|
|
21
|
+
* @deprecated Use `resolveRedisConnection` for ioredis/BullMQ configs, or `getRedisUrl` for URL strings.
|
|
22
|
+
*/
|
|
23
|
+
export const createRedisConfig = (options = {}) => {
|
|
24
|
+
const redisUrl = getRedisUrl();
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
url: redisUrl,
|
|
28
|
+
...options,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @deprecated Use `resolveRedisConnection` instead.
|
|
34
|
+
*/
|
|
35
|
+
export const createRedisClient = createRedisConfig;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pings Redis to verify connectivity.
|
|
39
|
+
* Dynamically imports ioredis so it doesn't fail at load time if the package is not installed.
|
|
40
|
+
* Creates a temporary connection, sends PING, expects PONG, then disconnects.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} [options]
|
|
43
|
+
* @param {number} [options.timeout=3000] - Connection/ping timeout in milliseconds.
|
|
44
|
+
* @returns {Promise<{ status: "ok", latencyMs: number } | { status: "error", message: string }>}
|
|
45
|
+
*/
|
|
46
|
+
export async function pingRedis({ timeout = 3000 } = {}) {
|
|
47
|
+
/** @type {import("ioredis").default | null} */
|
|
48
|
+
let client = null;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const { default: Redis } = await import("ioredis");
|
|
52
|
+
|
|
53
|
+
const conn = resolveRedisConnection();
|
|
54
|
+
|
|
55
|
+
client = new Redis({
|
|
56
|
+
...conn,
|
|
57
|
+
lazyConnect: true,
|
|
58
|
+
connectTimeout: timeout,
|
|
59
|
+
maxRetriesPerRequest: 0,
|
|
60
|
+
enableReadyCheck: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Suppress the "Unhandled error event" stderr noise that ioredis emits
|
|
64
|
+
// when a connection attempt fails. The error is still caught by the
|
|
65
|
+
// try/catch below - this listener just prevents Node from treating it
|
|
66
|
+
// as an unhandled EventEmitter error.
|
|
67
|
+
client.on("error", () => {});
|
|
68
|
+
|
|
69
|
+
await client.connect();
|
|
70
|
+
|
|
71
|
+
const start = performance.now();
|
|
72
|
+
const result = await new Promise((resolve, reject) => {
|
|
73
|
+
const timer = setTimeout(
|
|
74
|
+
() => reject(new Error("PING timed out")),
|
|
75
|
+
timeout,
|
|
76
|
+
);
|
|
77
|
+
client.ping().then(
|
|
78
|
+
(val) => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
resolve(val);
|
|
81
|
+
},
|
|
82
|
+
(err) => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
reject(err);
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
90
|
+
|
|
91
|
+
if (result !== "PONG") {
|
|
92
|
+
return {
|
|
93
|
+
status: "error",
|
|
94
|
+
message: `Unexpected PING response: ${result}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { status: "ok", latencyMs };
|
|
99
|
+
} catch (/** @type {any} */ error) {
|
|
100
|
+
let message;
|
|
101
|
+
if (
|
|
102
|
+
error?.code === "MODULE_NOT_FOUND" ||
|
|
103
|
+
error?.code === "ERR_MODULE_NOT_FOUND"
|
|
104
|
+
) {
|
|
105
|
+
message = "ioredis is not installed";
|
|
106
|
+
} else if (
|
|
107
|
+
error?.code === "ECONNREFUSED" ||
|
|
108
|
+
/connection is closed|ECONNREFUSED/i.test(error?.message ?? "")
|
|
109
|
+
) {
|
|
110
|
+
message = `Redis unreachable at ${getRedisUrl()}`;
|
|
111
|
+
} else {
|
|
112
|
+
message = error?.message ?? String(error);
|
|
113
|
+
}
|
|
114
|
+
return { status: "error", message };
|
|
115
|
+
} finally {
|
|
116
|
+
try {
|
|
117
|
+
await client?.disconnect();
|
|
118
|
+
} catch {
|
|
119
|
+
// ignore disconnect errors
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolves Redis connection config as an ioredis-compatible options object.
|
|
126
|
+
* Precedence: explicit override > REDIS_URL > REDIS_HOST/REDIS_PORT > localhost defaults.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} [override] - if provided, returned as-is (pass-through for caller-supplied config)
|
|
129
|
+
* @returns {{ host: string, port: number, password?: string, tls?: object }}
|
|
130
|
+
*/
|
|
131
|
+
export function resolveRedisConnection(override) {
|
|
132
|
+
if (override) return override;
|
|
133
|
+
|
|
134
|
+
if (process.env.REDIS_URL) {
|
|
135
|
+
try {
|
|
136
|
+
const u = new URL(process.env.REDIS_URL);
|
|
137
|
+
return {
|
|
138
|
+
host: u.hostname,
|
|
139
|
+
port: parseInt(u.port || "6379", 10),
|
|
140
|
+
...(u.password && { password: decodeURIComponent(u.password) }),
|
|
141
|
+
...(u.protocol === "rediss:" && { tls: {} }),
|
|
142
|
+
};
|
|
143
|
+
} catch {
|
|
144
|
+
// Malformed REDIS_URL - fall through to host/port env vars
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
host: process.env.REDIS_HOST || "localhost",
|
|
150
|
+
port: parseInt(process.env.REDIS_PORT || "6379", 10),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { getRedisUrl };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import { getRedisUrl, resolveRedisConnection } from "./redis.js";
|
|
4
|
+
|
|
5
|
+
describe("getRedisUrl", () => {
|
|
6
|
+
let originalEnv;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
originalEnv = { ...process.env };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
process.env = originalEnv;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns REDIS_URL when set", () => {
|
|
17
|
+
process.env.REDIS_URL = "redis://custom:6380";
|
|
18
|
+
delete process.env.REDIS_HOST;
|
|
19
|
+
delete process.env.REDIS_PORT;
|
|
20
|
+
|
|
21
|
+
assert.equal(getRedisUrl(), "redis://custom:6380");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("builds URL from REDIS_HOST and REDIS_PORT", () => {
|
|
25
|
+
delete process.env.REDIS_URL;
|
|
26
|
+
process.env.REDIS_HOST = "myhost";
|
|
27
|
+
process.env.REDIS_PORT = "6381";
|
|
28
|
+
|
|
29
|
+
assert.equal(getRedisUrl(), "redis://myhost:6381");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("defaults to localhost:6379", () => {
|
|
33
|
+
delete process.env.REDIS_URL;
|
|
34
|
+
delete process.env.REDIS_HOST;
|
|
35
|
+
delete process.env.REDIS_PORT;
|
|
36
|
+
|
|
37
|
+
assert.equal(getRedisUrl(), "redis://localhost:6379");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("REDIS_URL takes precedence over REDIS_HOST/REDIS_PORT", () => {
|
|
41
|
+
process.env.REDIS_URL = "redis://from-url:9999";
|
|
42
|
+
process.env.REDIS_HOST = "from-host";
|
|
43
|
+
process.env.REDIS_PORT = "1111";
|
|
44
|
+
|
|
45
|
+
assert.equal(getRedisUrl(), "redis://from-url:9999");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("resolveRedisConnection", () => {
|
|
50
|
+
let originalEnv;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
originalEnv = { ...process.env };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
process.env = originalEnv;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns override as-is when provided", () => {
|
|
61
|
+
const override = { host: "custom", port: 1234 };
|
|
62
|
+
assert.deepEqual(resolveRedisConnection(override), override);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("parses REDIS_URL into host/port", () => {
|
|
66
|
+
process.env.REDIS_URL = "redis://myredis:6380";
|
|
67
|
+
delete process.env.REDIS_HOST;
|
|
68
|
+
delete process.env.REDIS_PORT;
|
|
69
|
+
|
|
70
|
+
const result = resolveRedisConnection();
|
|
71
|
+
assert.equal(result.host, "myredis");
|
|
72
|
+
assert.equal(result.port, 6380);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("parses password from REDIS_URL", () => {
|
|
76
|
+
process.env.REDIS_URL = "redis://:s3cret@myredis:6380";
|
|
77
|
+
delete process.env.REDIS_HOST;
|
|
78
|
+
|
|
79
|
+
const result = resolveRedisConnection();
|
|
80
|
+
assert.equal(result.host, "myredis");
|
|
81
|
+
assert.equal(result.port, 6380);
|
|
82
|
+
assert.equal(result.password, "s3cret");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("decodes URL-encoded password", () => {
|
|
86
|
+
process.env.REDIS_URL = "redis://:p%40ss%3Aword@myredis:6380";
|
|
87
|
+
delete process.env.REDIS_HOST;
|
|
88
|
+
|
|
89
|
+
const result = resolveRedisConnection();
|
|
90
|
+
assert.equal(result.password, "p@ss:word");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("sets tls for rediss:// protocol", () => {
|
|
94
|
+
process.env.REDIS_URL = "rediss://secure-host:6380";
|
|
95
|
+
delete process.env.REDIS_HOST;
|
|
96
|
+
|
|
97
|
+
const result = resolveRedisConnection();
|
|
98
|
+
assert.equal(result.host, "secure-host");
|
|
99
|
+
assert.deepEqual(result.tls, {});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("defaults port to 6379 when REDIS_URL omits port", () => {
|
|
103
|
+
process.env.REDIS_URL = "redis://myredis";
|
|
104
|
+
delete process.env.REDIS_HOST;
|
|
105
|
+
|
|
106
|
+
const result = resolveRedisConnection();
|
|
107
|
+
assert.equal(result.port, 6379);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("falls back to REDIS_HOST/REDIS_PORT when REDIS_URL is absent", () => {
|
|
111
|
+
delete process.env.REDIS_URL;
|
|
112
|
+
process.env.REDIS_HOST = "fallback-host";
|
|
113
|
+
process.env.REDIS_PORT = "7777";
|
|
114
|
+
|
|
115
|
+
const result = resolveRedisConnection();
|
|
116
|
+
assert.equal(result.host, "fallback-host");
|
|
117
|
+
assert.equal(result.port, 7777);
|
|
118
|
+
assert.equal(result.password, undefined);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("defaults to localhost:6379 when no env vars are set", () => {
|
|
122
|
+
delete process.env.REDIS_URL;
|
|
123
|
+
delete process.env.REDIS_HOST;
|
|
124
|
+
delete process.env.REDIS_PORT;
|
|
125
|
+
|
|
126
|
+
const result = resolveRedisConnection();
|
|
127
|
+
assert.equal(result.host, "localhost");
|
|
128
|
+
assert.equal(result.port, 6379);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("REDIS_URL takes precedence over REDIS_HOST/REDIS_PORT", () => {
|
|
132
|
+
process.env.REDIS_URL = "redis://url-host:8888";
|
|
133
|
+
process.env.REDIS_HOST = "host-var";
|
|
134
|
+
process.env.REDIS_PORT = "9999";
|
|
135
|
+
|
|
136
|
+
const result = resolveRedisConnection();
|
|
137
|
+
assert.equal(result.host, "url-host");
|
|
138
|
+
assert.equal(result.port, 8888);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("falls back to REDIS_HOST/REDIS_PORT when REDIS_URL is malformed", () => {
|
|
142
|
+
process.env.REDIS_URL = "not-a-valid-url";
|
|
143
|
+
process.env.REDIS_HOST = "fallback-host";
|
|
144
|
+
process.env.REDIS_PORT = "6382";
|
|
145
|
+
|
|
146
|
+
const result = resolveRedisConnection();
|
|
147
|
+
assert.equal(result.host, "fallback-host");
|
|
148
|
+
assert.equal(result.port, 6382);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request/Response Logging Middleware
|
|
3
|
+
*
|
|
4
|
+
* Logs HTTP request and response details for debugging and auditing.
|
|
5
|
+
* Sanitizes sensitive data (passwords, tokens) from request bodies.
|
|
6
|
+
* Logs response bodies only for errors (4xx/5xx status codes).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { logger } from "./logger.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fields to sanitize from request bodies (replace with "[REDACTED]")
|
|
13
|
+
*/
|
|
14
|
+
const SENSITIVE_FIELDS = [
|
|
15
|
+
"password",
|
|
16
|
+
"token",
|
|
17
|
+
"secret",
|
|
18
|
+
"apiKey",
|
|
19
|
+
"api_key",
|
|
20
|
+
"authorization",
|
|
21
|
+
"passwordConfirmation",
|
|
22
|
+
"oldPassword",
|
|
23
|
+
"newPassword",
|
|
24
|
+
"currentPassword",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Recursively sanitize sensitive fields in an object
|
|
29
|
+
* @param {any} obj - Object to sanitize
|
|
30
|
+
* @param {string[]} fields - List of sensitive field names
|
|
31
|
+
* @returns {any} Sanitized object
|
|
32
|
+
*/
|
|
33
|
+
function sanitizeObject(obj, fields = SENSITIVE_FIELDS) {
|
|
34
|
+
if (!obj || typeof obj !== "object") {
|
|
35
|
+
return obj;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (Array.isArray(obj)) {
|
|
39
|
+
return obj.map((item) => sanitizeObject(item, fields));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sanitized = {};
|
|
43
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
44
|
+
const keyLower = key.toLowerCase();
|
|
45
|
+
if (fields.some((field) => keyLower.includes(field.toLowerCase()))) {
|
|
46
|
+
sanitized[key] = "[REDACTED]";
|
|
47
|
+
} else if (typeof value === "object" && value !== null) {
|
|
48
|
+
sanitized[key] = sanitizeObject(value, fields);
|
|
49
|
+
} else {
|
|
50
|
+
sanitized[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return sanitized;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create request logger options
|
|
58
|
+
* @typedef {Object} RequestLoggerOptions
|
|
59
|
+
* @property {boolean} [logRequestBody=true] - Log request body
|
|
60
|
+
* @property {boolean} [logResponseBody=false] - Log response body for all requests
|
|
61
|
+
* @property {boolean} [logErrorResponseBody=true] - Log response body only for errors
|
|
62
|
+
* @property {string[]} [sensitiveFields] - Custom sensitive field names to sanitize
|
|
63
|
+
* @property {number} [maxBodyLength=1000] - Max characters to log from body (prevents massive logs)
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a request logger middleware for Next.js API routes
|
|
68
|
+
*
|
|
69
|
+
* @param {RequestLoggerOptions} [options={}] - Logging options
|
|
70
|
+
* @returns {Function} Middleware function
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* import { createRequestLogger } from "@techstream/quark-core";
|
|
74
|
+
*
|
|
75
|
+
* const logRequest = createRequestLogger({
|
|
76
|
+
* logRequestBody: true,
|
|
77
|
+
* logErrorResponseBody: true,
|
|
78
|
+
* });
|
|
79
|
+
*
|
|
80
|
+
* export async function GET(request) {
|
|
81
|
+
* return logRequest(request, async () => {
|
|
82
|
+
* // Your handler logic
|
|
83
|
+
* return NextResponse.json({ data: "..." });
|
|
84
|
+
* });
|
|
85
|
+
* }
|
|
86
|
+
*/
|
|
87
|
+
export function createRequestLogger(options = {}) {
|
|
88
|
+
const {
|
|
89
|
+
logRequestBody = true,
|
|
90
|
+
logResponseBody = false,
|
|
91
|
+
logErrorResponseBody = true,
|
|
92
|
+
sensitiveFields = SENSITIVE_FIELDS,
|
|
93
|
+
maxBodyLength = 1000,
|
|
94
|
+
} = options;
|
|
95
|
+
|
|
96
|
+
return async (request, handler) => {
|
|
97
|
+
const startTime = Date.now();
|
|
98
|
+
const { method, url } = request;
|
|
99
|
+
const { pathname, searchParams } = new URL(url);
|
|
100
|
+
|
|
101
|
+
// Build log context
|
|
102
|
+
const logContext = {
|
|
103
|
+
method,
|
|
104
|
+
path: pathname,
|
|
105
|
+
query: Object.fromEntries(searchParams),
|
|
106
|
+
headers: {
|
|
107
|
+
"user-agent": request.headers.get("user-agent"),
|
|
108
|
+
"content-type": request.headers.get("content-type"),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Log request body for non-GET requests
|
|
113
|
+
if (logRequestBody && method !== "GET" && method !== "HEAD") {
|
|
114
|
+
try {
|
|
115
|
+
// Clone request to read body without consuming it
|
|
116
|
+
const clonedRequest = request.clone();
|
|
117
|
+
const contentType = request.headers.get("content-type") || "";
|
|
118
|
+
|
|
119
|
+
if (contentType.includes("application/json")) {
|
|
120
|
+
const body = await clonedRequest.json();
|
|
121
|
+
const sanitized = sanitizeObject(body, sensitiveFields);
|
|
122
|
+
const bodyStr = JSON.stringify(sanitized);
|
|
123
|
+
logContext.body =
|
|
124
|
+
bodyStr.length > maxBodyLength
|
|
125
|
+
? `${bodyStr.substring(0, maxBodyLength)}... [truncated]`
|
|
126
|
+
: bodyStr;
|
|
127
|
+
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
128
|
+
const text = await clonedRequest.text();
|
|
129
|
+
const params = new URLSearchParams(text);
|
|
130
|
+
const paramsObj = Object.fromEntries(params);
|
|
131
|
+
const sanitized = sanitizeObject(paramsObj, sensitiveFields);
|
|
132
|
+
logContext.body = sanitized;
|
|
133
|
+
} else {
|
|
134
|
+
logContext.body = `[${contentType || "unknown content-type"}]`;
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
logContext.bodyError = error.message;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
logger.info("Incoming request", logContext);
|
|
142
|
+
|
|
143
|
+
let response;
|
|
144
|
+
let responseError = null;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// Execute the handler
|
|
148
|
+
response = await handler(request);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
responseError = error;
|
|
151
|
+
throw error; // Re-throw to maintain error flow
|
|
152
|
+
} finally {
|
|
153
|
+
const duration = Date.now() - startTime;
|
|
154
|
+
|
|
155
|
+
const responseLog = {
|
|
156
|
+
method,
|
|
157
|
+
path: pathname,
|
|
158
|
+
duration: `${duration}ms`,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (response) {
|
|
162
|
+
responseLog.status = response.status;
|
|
163
|
+
|
|
164
|
+
// Log response body for errors or if explicitly enabled
|
|
165
|
+
const isError = response.status >= 400;
|
|
166
|
+
const shouldLogBody =
|
|
167
|
+
logResponseBody || (logErrorResponseBody && isError);
|
|
168
|
+
|
|
169
|
+
if (shouldLogBody) {
|
|
170
|
+
try {
|
|
171
|
+
const clonedResponse = response.clone();
|
|
172
|
+
const contentType = response.headers.get("content-type") || "";
|
|
173
|
+
|
|
174
|
+
if (contentType.includes("application/json")) {
|
|
175
|
+
const body = await clonedResponse.json();
|
|
176
|
+
const bodyStr = JSON.stringify(body);
|
|
177
|
+
responseLog.body =
|
|
178
|
+
bodyStr.length > maxBodyLength
|
|
179
|
+
? `${bodyStr.substring(0, maxBodyLength)}... [truncated]`
|
|
180
|
+
: bodyStr;
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
responseLog.bodyError = error.message;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (isError) {
|
|
188
|
+
logger.error("Request completed with error", responseLog);
|
|
189
|
+
} else {
|
|
190
|
+
logger.info("Request completed", responseLog);
|
|
191
|
+
}
|
|
192
|
+
} else if (responseError) {
|
|
193
|
+
responseLog.error = responseError.message;
|
|
194
|
+
responseLog.stack = responseError.stack;
|
|
195
|
+
logger.error("Request failed", responseLog);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return response;
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Default request logger instance
|
|
205
|
+
*/
|
|
206
|
+
export const logRequest = createRequestLogger();
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Export sanitization utility for standalone use
|
|
210
|
+
*/
|
|
211
|
+
export { sanitizeObject };
|