@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.
- package/.turbo/turbo-lint.log +7 -0
- package/.turbo/turbo-test.log +1376 -0
- package/README.md +419 -0
- package/package.json +29 -0
- package/src/auth/index.js +127 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +90 -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/csrf.js +118 -0
- package/src/csrf.test.js +157 -0
- package/src/email.js +140 -0
- package/src/email.test.js +259 -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/index.js +32 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mailhog.js +43 -0
- package/src/queue/index.js +214 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +96 -0
- package/src/testing/factories.js +93 -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
- package/test-imports.mjs +21 -0
|
@@ -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
|
+
}
|
package/src/csrf.test.js
ADDED
|
@@ -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
|
+
}
|