@techstream/quark-core 1.1.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.
@@ -0,0 +1,217 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import { createCache } from "./cache.js";
4
+
5
+ /**
6
+ * Creates a mock Redis client backed by a Map.
7
+ * Provides get, set, del, keys, and expire methods.
8
+ */
9
+ function createMockRedis() {
10
+ const store = new Map();
11
+ const expires = new Map();
12
+
13
+ return {
14
+ store,
15
+ expires,
16
+
17
+ async get(key) {
18
+ return store.has(key) ? store.get(key) : null;
19
+ },
20
+
21
+ async set(key, value, ...args) {
22
+ store.set(key, value);
23
+ // Support atomic SET key value EX seconds
24
+ if (args[0] === "EX" && args[1] != null) {
25
+ expires.set(key, args[1]);
26
+ }
27
+ },
28
+
29
+ async del(key) {
30
+ store.delete(key);
31
+ expires.delete(key);
32
+ },
33
+
34
+ async scan(cursor, ...args) {
35
+ // Simple mock: return all matching keys in one batch
36
+ const matchIdx = args.indexOf("MATCH");
37
+ const pattern = matchIdx !== -1 ? args[matchIdx + 1] : "*";
38
+ const regex = new RegExp(
39
+ `^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*")}$`,
40
+ );
41
+ const keys = [...store.keys()].filter((k) => regex.test(k));
42
+ return ["0", keys];
43
+ },
44
+
45
+ async expire(key, seconds) {
46
+ expires.set(key, seconds);
47
+ },
48
+ };
49
+ }
50
+
51
+ describe("createCache", () => {
52
+ let redis;
53
+ let cache;
54
+
55
+ beforeEach(() => {
56
+ redis = createMockRedis();
57
+ cache = createCache(redis, { prefix: "test:", defaultTTL: 60 });
58
+ });
59
+
60
+ describe("get", () => {
61
+ it("returns null for a missing key", async () => {
62
+ const result = await cache.get("nonexistent");
63
+ assert.equal(result, null);
64
+ });
65
+
66
+ it("returns parsed JSON for an existing key", async () => {
67
+ redis.store.set("test:hello", JSON.stringify({ a: 1 }));
68
+ const result = await cache.get("hello");
69
+ assert.deepEqual(result, { a: 1 });
70
+ });
71
+
72
+ it("returns null for invalid JSON", async () => {
73
+ redis.store.set("test:bad", "not-json{");
74
+ const result = await cache.get("bad");
75
+ assert.equal(result, null);
76
+ });
77
+ });
78
+
79
+ describe("set", () => {
80
+ it("round-trips JSON values through set/get", async () => {
81
+ const data = { users: [1, 2, 3], active: true };
82
+ await cache.set("data", data);
83
+ const result = await cache.get("data");
84
+ assert.deepEqual(result, data);
85
+ });
86
+
87
+ it("passes TTL to Redis expire", async () => {
88
+ await cache.set("key", "value", 120);
89
+ assert.equal(redis.expires.get("test:key"), 120);
90
+ });
91
+
92
+ it("uses defaultTTL when no TTL is provided", async () => {
93
+ await cache.set("key", "value");
94
+ assert.equal(redis.expires.get("test:key"), 60);
95
+ });
96
+ });
97
+
98
+ describe("del", () => {
99
+ it("removes a key", async () => {
100
+ await cache.set("gone", "bye");
101
+ await cache.del("gone");
102
+ const result = await cache.get("gone");
103
+ assert.equal(result, null);
104
+ });
105
+ });
106
+
107
+ describe("invalidate", () => {
108
+ it("deletes all keys matching a pattern", async () => {
109
+ await cache.set("user:1", "alice");
110
+ await cache.set("user:2", "bob");
111
+ await cache.set("post:1", "hello");
112
+
113
+ await cache.invalidate("user:*");
114
+
115
+ assert.equal(await cache.get("user:1"), null);
116
+ assert.equal(await cache.get("user:2"), null);
117
+ assert.notEqual(await cache.get("post:1"), null);
118
+ });
119
+
120
+ it("does nothing when no keys match", async () => {
121
+ await cache.set("a", 1);
122
+ await cache.invalidate("zzz:*");
123
+ assert.deepEqual(await cache.get("a"), 1);
124
+ });
125
+ });
126
+
127
+ describe("getOrSet", () => {
128
+ it("calls factory on cache miss and caches the result", async () => {
129
+ let called = 0;
130
+ const factory = async () => {
131
+ called++;
132
+ return { fresh: true };
133
+ };
134
+
135
+ const result = await cache.getOrSet("miss", factory);
136
+ assert.deepEqual(result, { fresh: true });
137
+ assert.equal(called, 1);
138
+
139
+ // Value should now be cached
140
+ const cached = await cache.get("miss");
141
+ assert.deepEqual(cached, { fresh: true });
142
+ });
143
+
144
+ it("returns cached value without calling factory on hit", async () => {
145
+ await cache.set("hit", { cached: true });
146
+
147
+ let called = 0;
148
+ const factory = async () => {
149
+ called++;
150
+ return { fresh: true };
151
+ };
152
+
153
+ const result = await cache.getOrSet("hit", factory);
154
+ assert.deepEqual(result, { cached: true });
155
+ assert.equal(called, 0);
156
+ });
157
+ });
158
+
159
+ describe("wrap", () => {
160
+ it("creates a cached function", async () => {
161
+ let calls = 0;
162
+ const expensive = async (x) => {
163
+ calls++;
164
+ return x * 2;
165
+ };
166
+
167
+ const cachedFn = cache.wrap(expensive, { keyPrefix: "double" });
168
+
169
+ const first = await cachedFn(5);
170
+ assert.equal(first, 10);
171
+ assert.equal(calls, 1);
172
+
173
+ const second = await cachedFn(5);
174
+ assert.equal(second, 10);
175
+ assert.equal(calls, 1); // Cache hit — factory not called again
176
+ });
177
+
178
+ it("uses keyGenerator when provided", async () => {
179
+ let _calls = 0;
180
+ const fn = async (a, b) => {
181
+ _calls++;
182
+ return a + b;
183
+ };
184
+
185
+ const cachedFn = cache.wrap(fn, {
186
+ keyPrefix: "sum",
187
+ keyGenerator: (a, b) => `${a}+${b}`,
188
+ });
189
+
190
+ const result = await cachedFn(2, 3);
191
+ assert.equal(result, 5);
192
+
193
+ // Verify the custom key was used
194
+ const storedKey = "test:sum:2+3";
195
+ assert.ok(redis.store.has(storedKey));
196
+ });
197
+
198
+ it("caches different args separately", async () => {
199
+ let calls = 0;
200
+ const fn = async (x) => {
201
+ calls++;
202
+ return x * 3;
203
+ };
204
+
205
+ const cachedFn = cache.wrap(fn, { keyPrefix: "triple" });
206
+
207
+ assert.equal(await cachedFn(1), 3);
208
+ assert.equal(await cachedFn(2), 6);
209
+ assert.equal(calls, 2);
210
+
211
+ // Both should now be cached
212
+ assert.equal(await cachedFn(1), 3);
213
+ assert.equal(await cachedFn(2), 6);
214
+ assert.equal(calls, 2);
215
+ });
216
+ });
217
+ });
package/src/csrf.js ADDED
@@ -0,0 +1,118 @@
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
+ if (
73
+ ["GET", "HEAD", "OPTIONS"].includes(method) ||
74
+ path.startsWith("/api/auth/")
75
+ ) {
76
+ return;
77
+ }
78
+
79
+ // Read the expected token from the HTTP-only cookie
80
+ const cookieHeader = request.headers.get("cookie") || "";
81
+ const cookieToken = parseCookieValue(cookieHeader, "csrf_token");
82
+
83
+ if (!cookieToken) {
84
+ throw new UnauthorizedError(
85
+ "CSRF token not found — call GET /api/csrf first",
86
+ );
87
+ }
88
+
89
+ validateCsrfToken(request, cookieToken);
90
+ }
91
+
92
+ /**
93
+ * Creates a CSRF-protected API route handler.
94
+ * Wraps your handler and automatically validates CSRF tokens.
95
+ * @param {Function} handler - Your API route handler
96
+ * @returns {Function} Wrapped handler with CSRF protection
97
+ */
98
+ export function withCsrfProtection(handler) {
99
+ return async (request, ...args) => {
100
+ requireCsrfToken(request);
101
+ return handler(request, ...args);
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Parse a single cookie value from a raw Cookie header string.
107
+ * @param {string} cookieHeader - Raw `Cookie` header value
108
+ * @param {string} name - Cookie name to look up
109
+ * @returns {string|undefined}
110
+ */
111
+ function parseCookieValue(cookieHeader, name) {
112
+ const match = cookieHeader
113
+ .split(";")
114
+ .map((c) => c.trim())
115
+ .find((c) => c.startsWith(`${name}=`));
116
+
117
+ return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
118
+ }
@@ -0,0 +1,157 @@
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 validates POST with cookie token", () => {
113
+ const token = generateCsrfToken();
114
+ const request = mockRequest({
115
+ method: "POST",
116
+ url: "http://localhost/api/posts",
117
+ headers: {
118
+ "x-csrf-token": token,
119
+ cookie: `csrf_token=${encodeURIComponent(token)}; other=value`,
120
+ },
121
+ });
122
+
123
+ assert.doesNotThrow(() => {
124
+ requireCsrfToken(request);
125
+ });
126
+ });
127
+
128
+ await t.test("requireCsrfToken throws when cookie is missing", () => {
129
+ const token = generateCsrfToken();
130
+ const request = mockRequest({
131
+ method: "POST",
132
+ url: "http://localhost/api/posts",
133
+ headers: { "x-csrf-token": token },
134
+ });
135
+
136
+ assert.throws(() => requireCsrfToken(request), /CSRF token not found/);
137
+ });
138
+
139
+ await t.test("withCsrfProtection wraps handler", async () => {
140
+ const token = generateCsrfToken();
141
+ const request = mockRequest({
142
+ method: "POST",
143
+ url: "http://localhost/api/posts",
144
+ headers: {
145
+ "x-csrf-token": token,
146
+ cookie: `csrf_token=${encodeURIComponent(token)}`,
147
+ },
148
+ });
149
+
150
+ const handler = async (req) => ({ body: "success", request: req });
151
+ const protectedHandler = withCsrfProtection(handler);
152
+ const result = await protectedHandler(request);
153
+
154
+ assert.strictEqual(result.body, "success");
155
+ assert.strictEqual(result.request, request);
156
+ });
157
+ });
package/src/email.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @techstream/quark-core - Email Service
3
+ * Supports SMTP (Nodemailer) and Resend providers
4
+ */
5
+
6
+ import { getMailhogSmtpConfig } from "./mailhog.js";
7
+
8
+ /**
9
+ * Create an SMTP-based email sender using Nodemailer
10
+ */
11
+ async function createSmtpTransport() {
12
+ const nodemailer = await import("nodemailer");
13
+
14
+ // Use explicit SMTP config if provided, otherwise fall back to Mailhog
15
+ const host = process.env.SMTP_HOST;
16
+ const port = process.env.SMTP_PORT;
17
+
18
+ let transportConfig;
19
+
20
+ if (host && port) {
21
+ // Production SMTP configuration
22
+ transportConfig = {
23
+ host,
24
+ port: parseInt(port, 10),
25
+ secure: process.env.SMTP_SECURE === "true",
26
+ connectionTimeout: 10_000,
27
+ greetingTimeout: 10_000,
28
+ socketTimeout: 10_000,
29
+ ...(process.env.SMTP_USER && {
30
+ auth: {
31
+ user: process.env.SMTP_USER,
32
+ pass: process.env.SMTP_PASSWORD,
33
+ },
34
+ }),
35
+ };
36
+ } else {
37
+ // Development: use Mailhog
38
+ const mailhogConfig = getMailhogSmtpConfig();
39
+ transportConfig = {
40
+ host: mailhogConfig.host,
41
+ port: mailhogConfig.port,
42
+ secure: false,
43
+ connectionTimeout: 10_000,
44
+ greetingTimeout: 10_000,
45
+ socketTimeout: 10_000,
46
+ };
47
+ }
48
+
49
+ return nodemailer.default.createTransport(transportConfig);
50
+ }
51
+
52
+ /**
53
+ * Send email via Resend HTTP API
54
+ */
55
+ async function sendViaResend(from, to, subject, html, text) {
56
+ const apiKey = process.env.RESEND_API_KEY;
57
+ if (!apiKey) {
58
+ throw new Error("RESEND_API_KEY environment variable is required when EMAIL_PROVIDER=resend");
59
+ }
60
+
61
+ const response = await fetch("https://api.resend.com/emails", {
62
+ method: "POST",
63
+ headers: {
64
+ "Authorization": `Bearer ${apiKey}`,
65
+ "Content-Type": "application/json",
66
+ },
67
+ body: JSON.stringify({
68
+ from,
69
+ to: Array.isArray(to) ? to : [to],
70
+ subject,
71
+ html,
72
+ ...(text && { text }),
73
+ }),
74
+ signal: AbortSignal.timeout(10_000),
75
+ });
76
+
77
+ if (!response.ok) {
78
+ const error = await response.json().catch(() => ({ message: response.statusText }));
79
+ throw new Error(`Resend API error: ${error.message || response.statusText}`);
80
+ }
81
+
82
+ return response.json();
83
+ }
84
+
85
+ /**
86
+ * Create an email service instance
87
+ *
88
+ * @param {Object} [options]
89
+ * @param {string} [options.provider] - "smtp" or "resend" (defaults to EMAIL_PROVIDER env var or "smtp")
90
+ * @param {string} [options.from] - Sender address (defaults to EMAIL_FROM env var)
91
+ * @returns {{ sendEmail: (to: string|string[], subject: string, html: string, text?: string) => Promise<Object> }}
92
+ */
93
+ export function createEmailService(options = {}) {
94
+ const provider = options.provider || process.env.EMAIL_PROVIDER || "smtp";
95
+ const from = options.from || process.env.EMAIL_FROM || "Quark <noreply@localhost>";
96
+
97
+ let smtpTransport = null;
98
+
99
+ return {
100
+ /**
101
+ * Send an email
102
+ * @param {string|string[]} to - Recipient email(s)
103
+ * @param {string} subject - Email subject
104
+ * @param {string} html - HTML body
105
+ * @param {string} [text] - Plain text body (optional)
106
+ * @returns {Promise<Object>} Send result
107
+ */
108
+ async sendEmail(to, subject, html, text) {
109
+ // Input validation
110
+ if (!to || (typeof to === "string" && !to.trim())) {
111
+ throw new Error("Email 'to' address is required");
112
+ }
113
+ if (!subject || typeof subject !== "string" || !subject.trim()) {
114
+ throw new Error("Email 'subject' is required");
115
+ }
116
+ if (!html || typeof html !== "string" || !html.trim()) {
117
+ throw new Error("Email 'html' body is required");
118
+ }
119
+
120
+ if (provider === "resend") {
121
+ return sendViaResend(from, to, subject, html, text);
122
+ }
123
+
124
+ // SMTP (Nodemailer) — default
125
+ if (!smtpTransport) {
126
+ smtpTransport = await createSmtpTransport();
127
+ }
128
+
129
+ const result = await smtpTransport.sendMail({
130
+ from,
131
+ to: Array.isArray(to) ? to.join(", ") : to,
132
+ subject,
133
+ html,
134
+ ...(text && { text }),
135
+ });
136
+
137
+ return { id: result.messageId, ...result };
138
+ },
139
+ };
140
+ }