@techstream/quark-core 1.2.0 → 1.4.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/package.json CHANGED
@@ -1,30 +1,34 @@
1
1
  {
2
- "name": "@techstream/quark-core",
3
- "version": "1.2.0",
4
- "type": "module",
5
- "main": "src/index.js",
6
- "exports": {
7
- ".": "./src/index.js",
8
- "./testing": "./src/testing/index.js"
9
- },
10
- "dependencies": {
11
- "bcryptjs": "^3.0.3",
12
- "bullmq": "^5.67.3",
13
- "ioredis": "^5.9.3",
14
- "next-auth": "5.0.0-beta.30",
15
- "nodemailer": "^6.10.1",
16
- "zod": "^4.3.6"
17
- },
18
- "devDependencies": {
19
- "@types/node": "^24.10.12"
20
- },
21
- "scripts": {
22
- "test": "node --test $(find src -name '*.test.js')",
23
- "lint": "biome format --write && biome check --write"
24
- },
25
- "publishConfig": {
26
- "registry": "https://registry.npmjs.org",
27
- "access": "public"
28
- },
29
- "license": "ISC"
30
- }
2
+ "name": "@techstream/quark-core",
3
+ "version": "1.4.0",
4
+ "type": "module",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./testing": "./src/testing/index.js"
9
+ },
10
+ "dependencies": {
11
+ "bcryptjs": "^3.0.3",
12
+ "bullmq": "^5.67.3",
13
+ "ioredis": "^5.9.3",
14
+ "next-auth": "5.0.0-beta.30",
15
+ "nodemailer": "^6.10.1",
16
+ "zod": "^4.3.6"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^24.10.12"
20
+ },
21
+ "files": [
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "publishConfig": {
26
+ "registry": "https://registry.npmjs.org",
27
+ "access": "public"
28
+ },
29
+ "license": "ISC",
30
+ "scripts": {
31
+ "test": "node --test $(find src -name '*.test.js')",
32
+ "lint": "biome format --write && biome check --write"
33
+ }
34
+ }
package/src/auth/index.js CHANGED
@@ -27,9 +27,7 @@ export const createAuthConfig = (options = {}) => {
27
27
  } = options;
28
28
 
29
29
  if (!secret) {
30
- throw new Error(
31
- "NEXTAUTH_SECRET must be set (env var or options.secret)",
32
- );
30
+ throw new Error("NEXTAUTH_SECRET must be set (env var or options.secret)");
33
31
  }
34
32
 
35
33
  return {
package/src/auth.test.js CHANGED
@@ -23,9 +23,7 @@ test("Auth Module", async (t) => {
23
23
  try {
24
24
  assert.throws(
25
25
  () => createAuthConfig(),
26
- (err) =>
27
- err instanceof Error &&
28
- /NEXTAUTH_SECRET/.test(err.message),
26
+ (err) => err instanceof Error && /NEXTAUTH_SECRET/.test(err.message),
29
27
  );
30
28
  } finally {
31
29
  if (orig !== undefined) process.env.NEXTAUTH_SECRET = orig;
package/src/cache.test.js CHANGED
@@ -31,7 +31,7 @@ function createMockRedis() {
31
31
  expires.delete(key);
32
32
  },
33
33
 
34
- async scan(cursor, ...args) {
34
+ async scan(_cursor, ...args) {
35
35
  // Simple mock: return all matching keys in one batch
36
36
  const matchIdx = args.indexOf("MATCH");
37
37
  const pattern = matchIdx !== -1 ? args[matchIdx + 1] : "*";
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @techstream/quark-core - Email Templates
3
+ *
4
+ * Reusable HTML email templates with plain-text fallbacks.
5
+ * Each template function returns { subject, html, text }.
6
+ */
7
+
8
+ /**
9
+ * Escape HTML entities to prevent XSS in user-provided values
10
+ * @param {string} str
11
+ * @returns {string}
12
+ */
13
+ function escapeHtml(str) {
14
+ return String(str)
15
+ .replace(/&/g, "&")
16
+ .replace(/</g, "&lt;")
17
+ .replace(/>/g, "&gt;")
18
+ .replace(/"/g, "&quot;")
19
+ .replace(/'/g, "&#039;");
20
+ }
21
+
22
+ /**
23
+ * Shared outer layout for all emails
24
+ * @param {string} body - Inner HTML content
25
+ * @returns {string}
26
+ */
27
+ function layout(body) {
28
+ return `<!DOCTYPE html>
29
+ <html lang="en">
30
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
31
+ <body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f4f4f5">
32
+ <table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px">
33
+ <tr><td align="center">
34
+ <table width="100%" style="max-width:560px;background:#fff;border-radius:8px;border:1px solid #e4e4e7;padding:32px">
35
+ <tr><td>${body}</td></tr>
36
+ </table>
37
+ <p style="color:#a1a1aa;font-size:12px;margin-top:24px">
38
+ You received this email because an account was created with this address.
39
+ </p>
40
+ </td></tr>
41
+ </table>
42
+ </body>
43
+ </html>`;
44
+ }
45
+
46
+ /**
47
+ * Welcome email — sent after user registration
48
+ *
49
+ * @param {{ name?: string, appName?: string, loginUrl?: string }} data
50
+ * @returns {{ subject: string, html: string, text: string }}
51
+ */
52
+ export function welcomeEmail(data = {}) {
53
+ const name = data.name ? escapeHtml(data.name) : "there";
54
+ const appName = data.appName || "Quark";
55
+ const loginUrl = data.loginUrl || "";
56
+
57
+ const buttonHtml = loginUrl
58
+ ? `<p style="text-align:center;margin:24px 0">
59
+ <a href="${escapeHtml(loginUrl)}" style="display:inline-block;padding:12px 24px;background:#18181b;color:#fff;text-decoration:none;border-radius:6px;font-weight:500">
60
+ Sign in to ${escapeHtml(appName)}
61
+ </a>
62
+ </p>`
63
+ : "";
64
+
65
+ const html = layout(`
66
+ <h1 style="margin:0 0 16px;font-size:24px;color:#18181b">Welcome, ${name}!</h1>
67
+ <p style="color:#3f3f46;line-height:1.6;margin:0 0 16px">
68
+ Your account has been created successfully. You can now sign in and start using ${escapeHtml(appName)}.
69
+ </p>
70
+ ${buttonHtml}
71
+ <p style="color:#71717a;font-size:14px;margin:0">
72
+ If you didn't create this account, you can safely ignore this email.
73
+ </p>
74
+ `);
75
+
76
+ const text = [
77
+ `Welcome, ${data.name || "there"}!`,
78
+ "",
79
+ `Your account has been created successfully. You can now sign in and start using ${appName}.`,
80
+ ...(loginUrl ? ["", `Sign in: ${loginUrl}`] : []),
81
+ "",
82
+ "If you didn't create this account, you can safely ignore this email.",
83
+ ].join("\n");
84
+
85
+ return { subject: `Welcome to ${appName}!`, html, text };
86
+ }
87
+
88
+ /**
89
+ * Password reset email — sent when user requests a reset
90
+ *
91
+ * @param {{ name?: string, resetUrl: string, appName?: string, expiresIn?: string }} data
92
+ * @returns {{ subject: string, html: string, text: string }}
93
+ */
94
+ export function passwordResetEmail(data) {
95
+ if (!data?.resetUrl) {
96
+ throw new Error("resetUrl is required for password reset email");
97
+ }
98
+
99
+ const name = data.name ? escapeHtml(data.name) : "there";
100
+ const appName = data.appName || "Quark";
101
+ const expiresIn = data.expiresIn || "1 hour";
102
+
103
+ const html = layout(`
104
+ <h1 style="margin:0 0 16px;font-size:24px;color:#18181b">Reset your password</h1>
105
+ <p style="color:#3f3f46;line-height:1.6;margin:0 0 16px">
106
+ Hi ${name}, we received a request to reset your ${escapeHtml(appName)} password.
107
+ </p>
108
+ <p style="text-align:center;margin:24px 0">
109
+ <a href="${escapeHtml(data.resetUrl)}" style="display:inline-block;padding:12px 24px;background:#18181b;color:#fff;text-decoration:none;border-radius:6px;font-weight:500">
110
+ Reset password
111
+ </a>
112
+ </p>
113
+ <p style="color:#71717a;font-size:14px;margin:0 0 8px">
114
+ This link will expire in ${escapeHtml(expiresIn)}.
115
+ </p>
116
+ <p style="color:#71717a;font-size:14px;margin:0">
117
+ If you didn't request this, you can safely ignore this email. Your password will not be changed.
118
+ </p>
119
+ `);
120
+
121
+ const text = [
122
+ `Reset your password`,
123
+ "",
124
+ `Hi ${data.name || "there"}, we received a request to reset your ${appName} password.`,
125
+ "",
126
+ `Reset your password: ${data.resetUrl}`,
127
+ "",
128
+ `This link will expire in ${expiresIn}.`,
129
+ "",
130
+ "If you didn't request this, you can safely ignore this email. Your password will not be changed.",
131
+ ].join("\n");
132
+
133
+ return { subject: `Reset your ${appName} password`, html, text };
134
+ }
@@ -0,0 +1,124 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import { passwordResetEmail, welcomeEmail } from "./email-templates.js";
4
+
5
+ test("welcomeEmail", async (t) => {
6
+ await t.test("returns subject, html, and text", () => {
7
+ const result = welcomeEmail({});
8
+ assert.ok(result.subject);
9
+ assert.ok(result.html);
10
+ assert.ok(result.text);
11
+ });
12
+
13
+ await t.test("uses default app name in subject", () => {
14
+ const result = welcomeEmail({});
15
+ assert.strictEqual(result.subject, "Welcome to Quark!");
16
+ });
17
+
18
+ await t.test("uses custom app name in subject", () => {
19
+ const result = welcomeEmail({ appName: "MyApp" });
20
+ assert.strictEqual(result.subject, "Welcome to MyApp!");
21
+ });
22
+
23
+ await t.test("includes user name in html when provided", () => {
24
+ const result = welcomeEmail({ name: "Alice" });
25
+ assert.ok(result.html.includes("Alice"));
26
+ assert.ok(result.text.includes("Alice"));
27
+ });
28
+
29
+ await t.test("uses fallback greeting when no name provided", () => {
30
+ const result = welcomeEmail({});
31
+ assert.ok(result.html.includes("there"));
32
+ assert.ok(result.text.includes("there"));
33
+ });
34
+
35
+ await t.test("includes login URL when provided", () => {
36
+ const result = welcomeEmail({ loginUrl: "https://app.test/login" });
37
+ assert.ok(result.html.includes("https://app.test/login"));
38
+ assert.ok(result.text.includes("https://app.test/login"));
39
+ });
40
+
41
+ await t.test("omits sign-in button when no login URL", () => {
42
+ const result = welcomeEmail({});
43
+ assert.ok(!result.html.includes("Sign In"));
44
+ });
45
+
46
+ await t.test("escapes HTML in user name", () => {
47
+ const result = welcomeEmail({ name: "<script>alert('xss')</script>" });
48
+ assert.ok(!result.html.includes("<script>"));
49
+ assert.ok(result.html.includes("&lt;script&gt;"));
50
+ });
51
+
52
+ await t.test("wraps content in html layout", () => {
53
+ const result = welcomeEmail({});
54
+ assert.ok(result.html.includes("<!DOCTYPE html>"));
55
+ assert.ok(result.html.includes("</html>"));
56
+ });
57
+ });
58
+
59
+ test("passwordResetEmail", async (t) => {
60
+ const validArgs = { resetUrl: "https://app.test/reset?token=abc123" };
61
+
62
+ await t.test("returns subject, html, and text", () => {
63
+ const result = passwordResetEmail(validArgs);
64
+ assert.ok(result.subject);
65
+ assert.ok(result.html);
66
+ assert.ok(result.text);
67
+ });
68
+
69
+ await t.test("throws when resetUrl is missing", () => {
70
+ assert.throws(() => passwordResetEmail({}), /resetUrl is required/);
71
+ });
72
+
73
+ await t.test("includes reset URL in html and text", () => {
74
+ const result = passwordResetEmail(validArgs);
75
+ assert.ok(result.html.includes(validArgs.resetUrl));
76
+ assert.ok(result.text.includes(validArgs.resetUrl));
77
+ });
78
+
79
+ await t.test("uses default app name", () => {
80
+ const result = passwordResetEmail(validArgs);
81
+ assert.ok(result.subject.includes("Quark"));
82
+ });
83
+
84
+ await t.test("uses custom app name", () => {
85
+ const result = passwordResetEmail({ ...validArgs, appName: "MyApp" });
86
+ assert.ok(result.subject.includes("MyApp"));
87
+ });
88
+
89
+ await t.test("includes user name when provided", () => {
90
+ const result = passwordResetEmail({ ...validArgs, name: "Bob" });
91
+ assert.ok(result.html.includes("Bob"));
92
+ assert.ok(result.text.includes("Bob"));
93
+ });
94
+
95
+ await t.test("uses default expiry time", () => {
96
+ const result = passwordResetEmail(validArgs);
97
+ assert.ok(result.html.includes("1 hour"));
98
+ assert.ok(result.text.includes("1 hour"));
99
+ });
100
+
101
+ await t.test("uses custom expiry time", () => {
102
+ const result = passwordResetEmail({
103
+ ...validArgs,
104
+ expiresIn: "30 minutes",
105
+ });
106
+ assert.ok(result.html.includes("30 minutes"));
107
+ assert.ok(result.text.includes("30 minutes"));
108
+ });
109
+
110
+ await t.test("escapes HTML in user name", () => {
111
+ const result = passwordResetEmail({
112
+ ...validArgs,
113
+ name: '<img src=x onerror="alert(1)">',
114
+ });
115
+ assert.ok(!result.html.includes("<img"));
116
+ assert.ok(result.html.includes("&lt;img"));
117
+ });
118
+
119
+ await t.test("wraps content in html layout", () => {
120
+ const result = passwordResetEmail(validArgs);
121
+ assert.ok(result.html.includes("<!DOCTYPE html>"));
122
+ assert.ok(result.html.includes("</html>"));
123
+ });
124
+ });
package/src/email.js CHANGED
@@ -55,13 +55,15 @@ async function createSmtpTransport() {
55
55
  async function sendViaResend(from, to, subject, html, text) {
56
56
  const apiKey = process.env.RESEND_API_KEY;
57
57
  if (!apiKey) {
58
- throw new Error("RESEND_API_KEY environment variable is required when EMAIL_PROVIDER=resend");
58
+ throw new Error(
59
+ "RESEND_API_KEY environment variable is required when EMAIL_PROVIDER=resend",
60
+ );
59
61
  }
60
62
 
61
63
  const response = await fetch("https://api.resend.com/emails", {
62
64
  method: "POST",
63
65
  headers: {
64
- "Authorization": `Bearer ${apiKey}`,
66
+ Authorization: `Bearer ${apiKey}`,
65
67
  "Content-Type": "application/json",
66
68
  },
67
69
  body: JSON.stringify({
@@ -75,8 +77,12 @@ async function sendViaResend(from, to, subject, html, text) {
75
77
  });
76
78
 
77
79
  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
+ const error = await response
81
+ .json()
82
+ .catch(() => ({ message: response.statusText }));
83
+ throw new Error(
84
+ `Resend API error: ${error.message || response.statusText}`,
85
+ );
80
86
  }
81
87
 
82
88
  return response.json();
@@ -84,7 +90,7 @@ async function sendViaResend(from, to, subject, html, text) {
84
90
 
85
91
  /**
86
92
  * Create an email service instance
87
- *
93
+ *
88
94
  * @param {Object} [options]
89
95
  * @param {string} [options.provider] - "smtp" or "resend" (defaults to EMAIL_PROVIDER env var or "smtp")
90
96
  * @param {string} [options.from] - Sender address (defaults to EMAIL_FROM env var)
@@ -92,7 +98,8 @@ async function sendViaResend(from, to, subject, html, text) {
92
98
  */
93
99
  export function createEmailService(options = {}) {
94
100
  const provider = options.provider || process.env.EMAIL_PROVIDER || "smtp";
95
- const from = options.from || process.env.EMAIL_FROM || "Quark <noreply@localhost>";
101
+ const from =
102
+ options.from || process.env.EMAIL_FROM || "Quark <noreply@localhost>";
96
103
 
97
104
  let smtpTransport = null;
98
105