@usequark/quark-core 2.6.4 → 2.8.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-core",
3
- "version": "2.6.4",
3
+ "version": "2.8.0",
4
4
  "description": "Core utilities for Quark apps: auth, BullMQ queues, Redis, email, storage, rate limiting, logging, and error handling",
5
5
  "keywords": [
6
6
  "quark",
package/src/auth/index.js CHANGED
@@ -8,6 +8,7 @@ import { createLogger } from "../logger.js";
8
8
 
9
9
  export * from "./middleware.js";
10
10
  export * from "./password.js";
11
+ export * from "./tokens.js";
11
12
 
12
13
  const logger = createLogger({ name: "auth" });
13
14
 
@@ -0,0 +1,49 @@
1
+ import crypto from "node:crypto";
2
+
3
+ /**
4
+ * @usequark/quark-core - Verification Token Helpers
5
+ *
6
+ * The primitives behind email-verification and password-reset links, kept in
7
+ * one place so both flows agree on how a token is minted and how it is stored.
8
+ */
9
+
10
+ /** 32 bytes of entropy, rendered as 64 hex characters. */
11
+ export const DEFAULT_TOKEN_BYTES = 32;
12
+
13
+ /**
14
+ * Generates a cryptographically random token for a one-time link.
15
+ *
16
+ * `randomBytes` is CSPRNG-backed — unlike `Math.random` or a timestamp, its
17
+ * output cannot be predicted from a sample. The token is the entire bearer
18
+ * credential in the emailed URL, so predictability is the whole attack.
19
+ *
20
+ * Hex rather than base64url because the token is both placed in a URL query
21
+ * string and stored in a database column; hex needs no percent-encoding and
22
+ * survives a copy/paste through an email client without case surprises.
23
+ *
24
+ * @param {number} [bytes=DEFAULT_TOKEN_BYTES] - Entropy in bytes
25
+ * @returns {string} Hex-encoded token
26
+ */
27
+ export function generateToken(bytes = DEFAULT_TOKEN_BYTES) {
28
+ return crypto.randomBytes(bytes).toString("hex");
29
+ }
30
+
31
+ /**
32
+ * Hashes a token for storage.
33
+ *
34
+ * The database is not the place to keep a credential that grants account
35
+ * access: a leaked dump, a backup, or a read-only reporting replica would hand
36
+ * an attacker a working reset link for every pending request. What is stored is
37
+ * the SHA-256 digest, so a leaked row cannot be redeemed. The emailed, raw
38
+ * token is the only copy that can be.
39
+ *
40
+ * A plain SHA-256 is sufficient here, unlike for passwords: the input is 256
41
+ * bits of uniform randomness, so brute-forcing the preimage is infeasible and
42
+ * there is nothing for a slow, salted KDF to defend against.
43
+ *
44
+ * @param {string} token - Raw token
45
+ * @returns {string} Hex-encoded SHA-256 digest
46
+ */
47
+ export function hashToken(token) {
48
+ return crypto.createHash("sha256").update(String(token)).digest("hex");
49
+ }
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { DEFAULT_TOKEN_BYTES, generateToken, hashToken } from "./tokens.js";
4
+
5
+ describe("generateToken", () => {
6
+ test("returns a hex string of 2 characters per byte", () => {
7
+ const token = generateToken();
8
+ assert.match(token, /^[0-9a-f]+$/);
9
+ assert.strictEqual(token.length, DEFAULT_TOKEN_BYTES * 2);
10
+ });
11
+
12
+ test("honours a custom byte length", () => {
13
+ assert.strictEqual(generateToken(16).length, 32);
14
+ });
15
+
16
+ test("does not repeat across calls", () => {
17
+ // A token used as a one-time link must not collide with another. 32 bytes
18
+ // makes a repeat effectively impossible; a regression to a counter, a
19
+ // timestamp, or a constant would surface here immediately.
20
+ const tokens = new Set(Array.from({ length: 100 }, () => generateToken()));
21
+ assert.strictEqual(tokens.size, 100);
22
+ });
23
+ });
24
+
25
+ describe("hashToken", () => {
26
+ test("is deterministic", () => {
27
+ assert.strictEqual(hashToken("abc"), hashToken("abc"));
28
+ });
29
+
30
+ test("produces a 64-character hex digest", () => {
31
+ assert.match(hashToken("abc"), /^[0-9a-f]{64}$/);
32
+ });
33
+
34
+ test("separates different inputs", () => {
35
+ assert.notStrictEqual(hashToken("abc"), hashToken("abd"));
36
+ });
37
+
38
+ test("does not embed the raw token", () => {
39
+ const token = "super-secret-reset-token";
40
+ assert.ok(!hashToken(token).includes(token));
41
+ });
42
+ });
@@ -132,3 +132,51 @@ export function passwordResetEmail(data) {
132
132
 
133
133
  return { subject: `Reset your ${appName} password`, html, text };
134
134
  }
135
+
136
+ /**
137
+ * Email verification email - sent to confirm ownership of an address
138
+ *
139
+ * @param {{ name?: string, verifyUrl: string, appName?: string, expiresIn?: string }} data
140
+ * @returns {{ subject: string, html: string, text: string }}
141
+ */
142
+ export function emailVerificationEmail(data) {
143
+ if (!data?.verifyUrl) {
144
+ throw new Error("verifyUrl is required for email verification email");
145
+ }
146
+
147
+ const name = data.name ? escapeHtml(data.name) : "there";
148
+ const appName = data.appName || "Quark";
149
+ const expiresIn = data.expiresIn || "24 hours";
150
+
151
+ const html = layout(`
152
+ <h1 style="margin:0 0 16px;font-size:24px;color:#18181b">Confirm your email</h1>
153
+ <p style="color:#3f3f46;line-height:1.6;margin:0 0 16px">
154
+ Hi ${name}, confirm this address to finish setting up your ${escapeHtml(appName)} account.
155
+ </p>
156
+ <p style="text-align:center;margin:24px 0">
157
+ <a href="${escapeHtml(data.verifyUrl)}" style="display:inline-block;padding:12px 24px;background:#18181b;color:#fff;text-decoration:none;border-radius:6px;font-weight:500">
158
+ Verify email
159
+ </a>
160
+ </p>
161
+ <p style="color:#71717a;font-size:14px;margin:0 0 8px">
162
+ This link will expire in ${escapeHtml(expiresIn)}.
163
+ </p>
164
+ <p style="color:#71717a;font-size:14px;margin:0">
165
+ If you didn't create this account, you can safely ignore this email — no one can sign in as you until the address is confirmed.
166
+ </p>
167
+ `);
168
+
169
+ const text = [
170
+ `Confirm your email`,
171
+ "",
172
+ `Hi ${data.name || "there"}, confirm this address to finish setting up your ${appName} account.`,
173
+ "",
174
+ `Verify email: ${data.verifyUrl}`,
175
+ "",
176
+ `This link will expire in ${expiresIn}.`,
177
+ "",
178
+ "If you didn't create this account, you can safely ignore this email — no one can sign in as you until the address is confirmed.",
179
+ ].join("\n");
180
+
181
+ return { subject: `Confirm your ${appName} email`, html, text };
182
+ }
@@ -1,6 +1,10 @@
1
1
  import assert from "node:assert";
2
2
  import { test } from "node:test";
3
- import { passwordResetEmail, welcomeEmail } from "./email-templates.js";
3
+ import {
4
+ emailVerificationEmail,
5
+ passwordResetEmail,
6
+ welcomeEmail,
7
+ } from "./email-templates.js";
4
8
 
5
9
  test("welcomeEmail", async (t) => {
6
10
  await t.test("returns subject, html, and text", () => {
@@ -122,3 +126,63 @@ test("passwordResetEmail", async (t) => {
122
126
  assert.ok(result.html.includes("</html>"));
123
127
  });
124
128
  });
129
+
130
+ test("emailVerificationEmail", async (t) => {
131
+ const validArgs = {
132
+ verifyUrl: "https://app.test/api/auth/verify-email?token=abc123",
133
+ };
134
+
135
+ await t.test("returns subject, html, and text", () => {
136
+ const result = emailVerificationEmail(validArgs);
137
+ assert.ok(result.subject);
138
+ assert.ok(result.html);
139
+ assert.ok(result.text);
140
+ });
141
+
142
+ await t.test("throws when verifyUrl is missing", () => {
143
+ assert.throws(() => emailVerificationEmail({}), /verifyUrl is required/);
144
+ });
145
+
146
+ await t.test("includes verify URL in html and text", () => {
147
+ const result = emailVerificationEmail(validArgs);
148
+ assert.ok(result.html.includes(validArgs.verifyUrl));
149
+ assert.ok(result.text.includes(validArgs.verifyUrl));
150
+ });
151
+
152
+ await t.test("uses default app name", () => {
153
+ const result = emailVerificationEmail(validArgs);
154
+ assert.ok(result.subject.includes("Quark"));
155
+ });
156
+
157
+ await t.test("uses custom app name", () => {
158
+ const result = emailVerificationEmail({ ...validArgs, appName: "MyApp" });
159
+ assert.ok(result.subject.includes("MyApp"));
160
+ });
161
+
162
+ await t.test("includes user name when provided", () => {
163
+ const result = emailVerificationEmail({ ...validArgs, name: "Bob" });
164
+ assert.ok(result.html.includes("Bob"));
165
+ assert.ok(result.text.includes("Bob"));
166
+ });
167
+
168
+ await t.test("uses default expiry time", () => {
169
+ const result = emailVerificationEmail(validArgs);
170
+ assert.ok(result.html.includes("24 hours"));
171
+ assert.ok(result.text.includes("24 hours"));
172
+ });
173
+
174
+ await t.test("escapes HTML in user name", () => {
175
+ const result = emailVerificationEmail({
176
+ ...validArgs,
177
+ name: '<img src=x onerror="alert(1)">',
178
+ });
179
+ assert.ok(!result.html.includes("<img"));
180
+ assert.ok(result.html.includes("&lt;img"));
181
+ });
182
+
183
+ await t.test("wraps content in html layout", () => {
184
+ const result = emailVerificationEmail(validArgs);
185
+ assert.ok(result.html.includes("<!DOCTYPE html>"));
186
+ assert.ok(result.html.includes("</html>"));
187
+ });
188
+ });
@@ -99,7 +99,13 @@ class MemoryRateLimiter {
99
99
  class RedisRateLimiter {
100
100
  constructor(redisClient, options = {}) {
101
101
  this.redis = redisClient;
102
- this.failOpen = options.failOpen ?? true;
102
+ // Fails CLOSED by default: if Redis errors mid-request, the request is
103
+ // treated as rate-limited rather than allowed through. A limiter that
104
+ // disables itself precisely when it is being hammered (the classic
105
+ // fail-open) is worst exactly when it matters. Opt back in with
106
+ // `failOpen: true` only if an outage-driven 429 is worse for you than
107
+ // an attacker-driven flood.
108
+ this.failOpen = options.failOpen ?? false;
103
109
  }
104
110
 
105
111
  /**
@@ -127,4 +127,48 @@ test("Rate Limiter Module", async (t) => {
127
127
  limiter.stopCleanup();
128
128
  assert(limiter.cleanupInterval === null);
129
129
  });
130
+
131
+ // A Redis-backed limiter that fails OPEN disables itself precisely when it
132
+ // is being hammered — Redis erroring under load is the moment an attacker
133
+ // most needs the limiter to hold. Default must be fail-CLOSED.
134
+ await t.test(
135
+ "Redis limiter fails CLOSED by default when Redis errors",
136
+ async () => {
137
+ const limiter = createRateLimiter({
138
+ type: "redis",
139
+ redisClient: {
140
+ eval: async () => {
141
+ throw new Error("redis down");
142
+ },
143
+ },
144
+ });
145
+
146
+ const result = await limiter.checkLimit("k", 100, 60000);
147
+ assert.strictEqual(
148
+ result.limited,
149
+ true,
150
+ "must block, not allow, on error",
151
+ );
152
+ assert.strictEqual(result.remaining, 0);
153
+ },
154
+ );
155
+
156
+ await t.test(
157
+ "Redis limiter can opt back into fail-open explicitly",
158
+ async () => {
159
+ const limiter = createRateLimiter({
160
+ type: "redis",
161
+ failOpen: true,
162
+ redisClient: {
163
+ eval: async () => {
164
+ throw new Error("redis down");
165
+ },
166
+ },
167
+ });
168
+
169
+ const result = await limiter.checkLimit("k", 100, 60000);
170
+ assert.strictEqual(result.limited, false);
171
+ assert.strictEqual(result.remaining, 100);
172
+ },
173
+ );
130
174
  });