@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
package/src/csrf.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @techstream/quark-core - CSRF Protection Module
3
+ * Provides CSRF token generation and validation for API routes
4
+ */
5
+
6
+ import crypto from "node:crypto";
7
+ import { UnauthorizedError } from "./errors.js";
8
+
9
+ /**
10
+ * Generates a cryptographically secure CSRF token
11
+ * @returns {string} CSRF token
12
+ */
13
+ export function generateCsrfToken() {
14
+ return crypto.randomBytes(32).toString("base64");
15
+ }
16
+
17
+ /**
18
+ * Validates CSRF token from request headers
19
+ * @param {Request} request - Next.js Request object
20
+ * @param {string} sessionToken - Expected CSRF token from session/cookie
21
+ * @throws {UnauthorizedError} If CSRF token is missing or invalid
22
+ * @returns {boolean} True if valid
23
+ */
24
+ export function validateCsrfToken(request, sessionToken) {
25
+ const headerToken =
26
+ request.headers.get("x-csrf-token") || request.headers.get("csrf-token");
27
+
28
+ if (!headerToken) {
29
+ throw new UnauthorizedError("CSRF token missing");
30
+ }
31
+
32
+ if (!sessionToken) {
33
+ throw new UnauthorizedError("No CSRF token in session");
34
+ }
35
+
36
+ // Constant-time comparison to prevent timing attacks
37
+ const headerBuf = Buffer.from(headerToken);
38
+ const sessionBuf = Buffer.from(sessionToken);
39
+
40
+ if (
41
+ headerBuf.length !== sessionBuf.length ||
42
+ !crypto.timingSafeEqual(headerBuf, sessionBuf)
43
+ ) {
44
+ throw new UnauthorizedError("Invalid CSRF token");
45
+ }
46
+
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Middleware to require CSRF token for state-changing methods.
52
+ *
53
+ * Validation flow:
54
+ * 1. Read the expected token from the `csrf_token` HTTP-only cookie
55
+ * (set by the `/api/csrf` endpoint).
56
+ * 2. Compare it against the `X-CSRF-Token` (or `CSRF-Token`) request header
57
+ * that the client attaches to every mutating request.
58
+ *
59
+ * NextAuth already handles CSRF for /api/auth/* routes, so those are skipped.
60
+ *
61
+ * @param {Request} request - Next.js Request object
62
+ * @returns {void}
63
+ * @throws {UnauthorizedError} If CSRF validation fails
64
+ */
65
+ export function requireCsrfToken(request) {
66
+ const method = request.method;
67
+ const path = new URL(request.url).pathname;
68
+
69
+ // Skip CSRF check for:
70
+ // - Safe methods (GET, HEAD, OPTIONS)
71
+ // - NextAuth routes (they have their own CSRF protection)
72
+ // - Bearer-authenticated requests (e.g. mobile app) - CSRF exploits ambient
73
+ // cookie credentials; an Authorization header is not sent automatically
74
+ // by browsers, so cross-site forgery is not possible.
75
+ if (
76
+ ["GET", "HEAD", "OPTIONS"].includes(method) ||
77
+ path.startsWith("/api/auth/") ||
78
+ request.headers.get("authorization")?.startsWith("Bearer ")
79
+ ) {
80
+ return;
81
+ }
82
+
83
+ // Read the expected token from the HTTP-only cookie
84
+ const cookieHeader = request.headers.get("cookie") || "";
85
+ const cookieToken = parseCookieValue(cookieHeader, "csrf_token");
86
+
87
+ if (!cookieToken) {
88
+ throw new UnauthorizedError(
89
+ "CSRF token not found - call GET /api/csrf first",
90
+ );
91
+ }
92
+
93
+ validateCsrfToken(request, cookieToken);
94
+ }
95
+
96
+ /**
97
+ * Creates a CSRF-protected API route handler.
98
+ * Wraps your handler and automatically validates CSRF tokens.
99
+ * @param {Function} handler - Your API route handler
100
+ * @returns {Function} Wrapped handler with CSRF protection
101
+ */
102
+ export function withCsrfProtection(handler) {
103
+ return async (request, ...args) => {
104
+ requireCsrfToken(request);
105
+ return handler(request, ...args);
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Parse a single cookie value from a raw Cookie header string.
111
+ * @param {string} cookieHeader - Raw `Cookie` header value
112
+ * @param {string} name - Cookie name to look up
113
+ * @returns {string|undefined}
114
+ */
115
+ function parseCookieValue(cookieHeader, name) {
116
+ const match = cookieHeader
117
+ .split(";")
118
+ .map((c) => c.trim())
119
+ .find((c) => c.startsWith(`${name}=`));
120
+
121
+ return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
122
+ }
@@ -0,0 +1,169 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import {
4
+ generateCsrfToken,
5
+ requireCsrfToken,
6
+ validateCsrfToken,
7
+ withCsrfProtection,
8
+ } from "../src/csrf.js";
9
+
10
+ /**
11
+ * Creates a mock Request-like object for testing.
12
+ * @param {Object} opts
13
+ * @param {string} [opts.method="GET"]
14
+ * @param {string} [opts.url="http://localhost/api/test"]
15
+ * @param {Record<string, string>} [opts.headers={}] - lowercase header names
16
+ */
17
+ function mockRequest({
18
+ method = "GET",
19
+ url = "http://localhost/api/test",
20
+ headers = {},
21
+ } = {}) {
22
+ const map = new Map(Object.entries(headers));
23
+ return {
24
+ method,
25
+ url,
26
+ headers: {
27
+ get(key) {
28
+ return map.get(key) ?? null;
29
+ },
30
+ },
31
+ };
32
+ }
33
+
34
+ test("CSRF Module", async (t) => {
35
+ await t.test("generateCsrfToken creates a secure token", () => {
36
+ const token1 = generateCsrfToken();
37
+ const token2 = generateCsrfToken();
38
+
39
+ assert(token1.length > 0);
40
+ assert(token2.length > 0);
41
+ assert(token1 !== token2, "Tokens should be unique");
42
+ });
43
+
44
+ await t.test("validateCsrfToken accepts valid token", () => {
45
+ const token = generateCsrfToken();
46
+ const request = mockRequest({ headers: { "x-csrf-token": token } });
47
+
48
+ assert.doesNotThrow(() => {
49
+ validateCsrfToken(request, token);
50
+ });
51
+ });
52
+
53
+ await t.test("validateCsrfToken rejects missing header token", () => {
54
+ const token = generateCsrfToken();
55
+ const request = mockRequest();
56
+
57
+ assert.throws(
58
+ () => validateCsrfToken(request, token),
59
+ /CSRF token missing/,
60
+ );
61
+ });
62
+
63
+ await t.test("validateCsrfToken rejects missing session token", () => {
64
+ const request = mockRequest({ headers: { "x-csrf-token": "some-token" } });
65
+
66
+ assert.throws(
67
+ () => validateCsrfToken(request, null),
68
+ /No CSRF token in session/,
69
+ );
70
+ });
71
+
72
+ await t.test("validateCsrfToken rejects mismatched tokens", () => {
73
+ const token = generateCsrfToken();
74
+ const request = mockRequest({
75
+ headers: { "x-csrf-token": "wrong-token-of-same-len" },
76
+ });
77
+
78
+ assert.throws(
79
+ () => validateCsrfToken(request, token),
80
+ /Invalid CSRF token/,
81
+ );
82
+ });
83
+
84
+ await t.test("validateCsrfToken rejects tokens of different lengths", () => {
85
+ const request = mockRequest({ headers: { "x-csrf-token": "short" } });
86
+
87
+ assert.throws(
88
+ () => validateCsrfToken(request, "a-much-longer-session-token"),
89
+ /Invalid CSRF token/,
90
+ );
91
+ });
92
+
93
+ await t.test("requireCsrfToken skips GET requests", () => {
94
+ const request = mockRequest({ method: "GET" });
95
+
96
+ assert.doesNotThrow(() => {
97
+ requireCsrfToken(request);
98
+ });
99
+ });
100
+
101
+ await t.test("requireCsrfToken skips NextAuth routes", () => {
102
+ const request = mockRequest({
103
+ method: "POST",
104
+ url: "http://localhost/api/auth/signin",
105
+ });
106
+
107
+ assert.doesNotThrow(() => {
108
+ requireCsrfToken(request);
109
+ });
110
+ });
111
+
112
+ await t.test("requireCsrfToken skips Bearer-authenticated requests", () => {
113
+ const request = mockRequest({
114
+ method: "POST",
115
+ url: "http://localhost/api/device/register",
116
+ headers: { authorization: "Bearer some-jwt-token" },
117
+ });
118
+
119
+ assert.doesNotThrow(() => {
120
+ requireCsrfToken(request);
121
+ });
122
+ });
123
+
124
+ await t.test("requireCsrfToken validates POST with cookie token", () => {
125
+ const token = generateCsrfToken();
126
+ const request = mockRequest({
127
+ method: "POST",
128
+ url: "http://localhost/api/posts",
129
+ headers: {
130
+ "x-csrf-token": token,
131
+ cookie: `csrf_token=${encodeURIComponent(token)}; other=value`,
132
+ },
133
+ });
134
+
135
+ assert.doesNotThrow(() => {
136
+ requireCsrfToken(request);
137
+ });
138
+ });
139
+
140
+ await t.test("requireCsrfToken throws when cookie is missing", () => {
141
+ const token = generateCsrfToken();
142
+ const request = mockRequest({
143
+ method: "POST",
144
+ url: "http://localhost/api/posts",
145
+ headers: { "x-csrf-token": token },
146
+ });
147
+
148
+ assert.throws(() => requireCsrfToken(request), /CSRF token not found/);
149
+ });
150
+
151
+ await t.test("withCsrfProtection wraps handler", async () => {
152
+ const token = generateCsrfToken();
153
+ const request = mockRequest({
154
+ method: "POST",
155
+ url: "http://localhost/api/posts",
156
+ headers: {
157
+ "x-csrf-token": token,
158
+ cookie: `csrf_token=${encodeURIComponent(token)}`,
159
+ },
160
+ });
161
+
162
+ const handler = async (req) => ({ body: "success", request: req });
163
+ const protectedHandler = withCsrfProtection(handler);
164
+ const result = await protectedHandler(request);
165
+
166
+ assert.strictEqual(result.body, "success");
167
+ assert.strictEqual(result.request, request);
168
+ });
169
+ });
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @techstream/quark-core — Database Instrumentation Module
3
+ *
4
+ * Schema-agnostic Prisma $extends middleware that monitors query execution
5
+ * times and logs slow queries. All query arguments are automatically masked
6
+ * to prevent sensitive data (passwords, tokens, hashes) from reaching logs.
7
+ *
8
+ * Agnostic by design: no project-specific thresholds or resource limits are
9
+ * hardcoded. All configuration comes from environment variables.
10
+ *
11
+ * Usage in client.js:
12
+ *
13
+ * import { createDbInstrumentation } from "@techstream/quark-core";
14
+ * const base = new PrismaClient({ ... });
15
+ * const ext = createDbInstrumentation();
16
+ * if (ext) prisma = base.$extends(ext);
17
+ */
18
+
19
+ import { createLogger } from "./logger.js";
20
+
21
+ const log = createLogger("db:instrumentation");
22
+
23
+ // ── Sensitive field patterns — anything matching is redacted ────────
24
+ const SENSITIVE_FIELD_PATTERNS = [
25
+ /^passw(?:or)?d$/i,
26
+ /^secret$/i,
27
+ /^token$/i,
28
+ /^api[_-]?key/i,
29
+ /^auth[_-]?token/i,
30
+ /^refresh[_-]?token/i,
31
+ /^access[_-]?token/i,
32
+ /^session[_-]?token/i,
33
+ /^reset[_-]?token/i,
34
+ /^verify[_-]?token/i,
35
+ /^csrf[_-]?token/i,
36
+ /^hash(?:ed)?$/i,
37
+ /^pin$/i,
38
+ /^otp$/i,
39
+ /^otp[_-]?secret$/i,
40
+ /^totp[_-]?secret$/i,
41
+ /^recovery[_-]?code/i,
42
+ /^private[_-]?key/i,
43
+ /^ssh[_-]?key/i,
44
+ /^pem$/i,
45
+ /^certificate$/i,
46
+ /^id[_-]?token/i,
47
+ /^code[_-]?verifier$/i,
48
+ /^code[_-]?challenge$/i,
49
+ /^nonce$/i,
50
+ /^signature$/i,
51
+ /^bearer$/i,
52
+ /^authorization$/i,
53
+ /^cookie$/i,
54
+ /^set[_-]?cookie/i,
55
+ /^x[_-]?csrf[_-]?token/i,
56
+ /^x[_-]?xsrf[_-]?token/i,
57
+ /^stripe[_-]?secret/i,
58
+ /^stripe[_-]?key/i,
59
+ ];
60
+
61
+ /** Value that appears in logs in place of a redacted field. */
62
+ const REDACTED_PLACEHOLDER = "[REDACTED]";
63
+
64
+ /** Default slow-query threshold in milliseconds. */
65
+ const DEFAULT_SLOW_THRESHOLD_MS = 500;
66
+
67
+ /**
68
+ * Returns the slow-query threshold from env, falling back to the default.
69
+ * @returns {number}
70
+ */
71
+ function getSlowThreshold() {
72
+ const fromEnv = Number(process.env.DB_SLOW_QUERY_THRESHOLD);
73
+ return Number.isFinite(fromEnv) && fromEnv > 0
74
+ ? fromEnv
75
+ : DEFAULT_SLOW_THRESHOLD_MS;
76
+ }
77
+
78
+ /**
79
+ * Returns true if instrumentation is enabled via environment variable.
80
+ * @returns {boolean}
81
+ */
82
+ function isInstrumentationEnabled() {
83
+ const setting = process.env.DB_INSTRUMENTATION;
84
+ if (setting === "0" || setting === "false" || setting === "off") return false;
85
+ return true;
86
+ }
87
+
88
+ // ── Argument masking ───────────────────────────────────────────────
89
+
90
+ /**
91
+ * Recursively walks a value and replaces any key that matches a
92
+ * sensitive-field pattern with a placeholder. Handles nested objects,
93
+ * arrays, and primitive values safely.
94
+ *
95
+ * @param {unknown} value — The value to mask.
96
+ * @returns {unknown} — The masked copy (shallow clone for non-objects).
97
+ */
98
+ function maskSensitiveValues(value) {
99
+ // Primitives pass through as-is (leaf values that happen to be strings
100
+ // are not masked — we only mask *keys* named like sensitive fields).
101
+ if (value === null || value === undefined || typeof value !== "object") {
102
+ return value;
103
+ }
104
+
105
+ // Arrays — recurse into each element.
106
+ if (Array.isArray(value)) {
107
+ return value.map(maskSensitiveValues);
108
+ }
109
+
110
+ // Plain object — clone and mask matching keys.
111
+ const masked = /** @type {Record<string, unknown>} */ ({});
112
+ for (const [key, val] of Object.entries(
113
+ /** @type {Record<string, unknown>} */ (value),
114
+ )) {
115
+ if (isSensitiveKey(key)) {
116
+ masked[key] = REDACTED_PLACEHOLDER;
117
+ } else {
118
+ masked[key] = maskSensitiveValues(val);
119
+ }
120
+ }
121
+ return masked;
122
+ }
123
+
124
+ /**
125
+ * Checks whether a key name matches any sensitive-field pattern.
126
+ * @param {string} key
127
+ * @returns {boolean}
128
+ */
129
+ function isSensitiveKey(key) {
130
+ return SENSITIVE_FIELD_PATTERNS.some((re) => re.test(key));
131
+ }
132
+
133
+ // ── Query-log argument summarisation ───────────────────────────────
134
+
135
+ /**
136
+ * Produces a safe, compact summary of query arguments suitable for
137
+ * debug-level logging. Masks sensitive fields and truncates large
138
+ * structures.
139
+ *
140
+ * @param {unknown} args — The raw Prisma query args.
141
+ * @returns {Record<string, unknown>}
142
+ */
143
+ function summarizeArgs(args) {
144
+ if (!args || typeof args !== "object") return {};
145
+
146
+ const masked = maskSensitiveValues(args);
147
+
148
+ // Truncate "data" and "where" large blocks to avoid log spam.
149
+ if (masked.data && typeof masked.data === "object") {
150
+ masked.data = truncateDeep(masked.data, 3);
151
+ }
152
+ if (masked.where && typeof masked.where === "object") {
153
+ masked.where = truncateDeep(masked.where, 3);
154
+ }
155
+ return /** @type {Record<string, unknown>} */ (masked);
156
+ }
157
+
158
+ /**
159
+ * Deep-truncates an object: keeps at most `maxKeys` keys per level.
160
+ * Beyond the limit, a `[+N more]` marker is added.
161
+ *
162
+ * @param {Record<string, unknown>} obj
163
+ * @param {number} maxKeys
164
+ * @returns {Record<string, unknown>}
165
+ */
166
+ function truncateDeep(obj, maxKeys) {
167
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
168
+
169
+ const keys = Object.keys(obj);
170
+ if (keys.length <= maxKeys) {
171
+ const result = /** @type {Record<string, unknown>} */ ({});
172
+ for (const key of keys) {
173
+ result[key] =
174
+ typeof obj[key] === "object" && obj[key] !== null
175
+ ? truncateDeep(
176
+ /** @type {Record<string, unknown>} */ (obj[key]),
177
+ maxKeys,
178
+ )
179
+ : obj[key];
180
+ }
181
+ return result;
182
+ }
183
+
184
+ const result = /** @type {Record<string, unknown>} */ ({});
185
+ const kept = keys.slice(0, maxKeys);
186
+ for (const key of kept) {
187
+ result[key] =
188
+ typeof obj[key] === "object" && obj[key] !== null
189
+ ? truncateDeep(
190
+ /** @type {Record<string, unknown>} */ (obj[key]),
191
+ maxKeys,
192
+ )
193
+ : obj[key];
194
+ }
195
+ result[`[+${keys.length - maxKeys} more]`] = true;
196
+ return result;
197
+ }
198
+
199
+ // ── Public API ─────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * Creates a Prisma client extension object that instruments all queries.
203
+ *
204
+ * The extension wraps every Prisma operation to:
205
+ * 1. Measure execution duration.
206
+ * 2. Log slow queries (configurable via DB_SLOW_QUERY_THRESHOLD).
207
+ * 3. Mask sensitive fields in all log output.
208
+ * 4. Increment Prometheus metrics for query counts and durations.
209
+ *
210
+ * Returns `null` when DB_INSTRUMENTATION is explicitly disabled, so the
211
+ * caller can skip the $extends call entirely (zero overhead).
212
+ *
213
+ * @returns {import("@prisma/client").PrismaClientExtended | null}
214
+ */
215
+ export function createDbInstrumentation() {
216
+ if (!isInstrumentationEnabled()) return null;
217
+
218
+ const slowThreshold = getSlowThreshold();
219
+
220
+ return {
221
+ name: "quark-db-instrumentation",
222
+ query: {
223
+ /**
224
+ * Middleware invoked for every Prisma operation.
225
+ * @param {Object} params
226
+ * @param {string} params.model — Model name (e.g. "User", "Account")
227
+ * @param {string} params.operation — Operation name (e.g. "findUnique", "create")
228
+ * @param {unknown} params.args — Query arguments (masked before logging)
229
+ * @param {Function} params.query — The inner query function to call
230
+ * @returns {Promise<unknown>}
231
+ */
232
+ async $allOperations({ model, operation, args, query }) {
233
+ const start = performance.now();
234
+
235
+ try {
236
+ const result = await query(args);
237
+ return result;
238
+ } finally {
239
+ const durationMs = performance.now() - start;
240
+
241
+ // Emit Prometheus metrics (best-effort, no throw)
242
+ try {
243
+ const { dbQueriesTotal, dbQueryDuration } = await import(
244
+ "./db-metrics.js"
245
+ );
246
+ dbQueriesTotal.inc({ model, operation });
247
+ dbQueryDuration.observe({ model, operation }, durationMs / 1000);
248
+ } catch {
249
+ // Metrics registry not registered — skip silently.
250
+ }
251
+
252
+ // Slow-query warning (masked args, no sensitive data)
253
+ if (durationMs > slowThreshold) {
254
+ log.warn("Slow query detected", {
255
+ model,
256
+ operation,
257
+ durationMs: Math.round(durationMs),
258
+ args: summarizeArgs(args),
259
+ });
260
+ } else if (process.env.LOG_LEVEL === "debug") {
261
+ log.debug("Query executed", {
262
+ model,
263
+ operation,
264
+ durationMs: Math.round(durationMs),
265
+ args: summarizeArgs(args),
266
+ });
267
+ }
268
+ }
269
+ },
270
+ },
271
+ };
272
+ }
273
+
274
+ // Re-export for testing
275
+ export {
276
+ getSlowThreshold,
277
+ isSensitiveKey,
278
+ maskSensitiveValues,
279
+ REDACTED_PLACEHOLDER,
280
+ summarizeArgs,
281
+ };