@usequark/quark-core 2.6.0 → 2.6.2

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/README.md CHANGED
@@ -4,15 +4,21 @@ Shared infrastructure for the Quark platform - authentication, job queues, error
4
4
 
5
5
  ## What's Included
6
6
 
7
- - **Authentication** - Auth.js-compatible config helpers (`createAuthConfig`, `requireAuth`, password hashing) plus framework-agnostic session guards (`requireSession`, `requireSessionRole`, `createAuthMiddleware`)
8
- - **Database** - Prisma client singleton (`createPrismaClient`), connection-string/pool helpers, and a `pingDatabase()` health check
9
- - **Admin** - Prisma schema introspection (`parseSchema`, `getModels`, `getEnums`, `coerceId`) for auto-generated admin UIs
10
- - **Job Queue** - BullMQ integration (`createQueue`, `createWorker`, `addJob`)
11
- - **Storage** - Local filesystem built in; S3/R2/MinIO via optional AWS SDK install in the app
12
- - **Errors** - Standardized error types (`ValidationError`, `NotFoundError`, `UnauthorizedError`, etc.)
13
- - **Utilities** - `retryAsync`, `deepMerge`, `randomString`, `sanitizeId`, `measureTime`, `memoize`
7
+ - **Authentication** - Auth.js-compatible config helpers (`createAuthConfig`, `requireAuth`, password hashing) plus framework-agnostic session guards (`requireSession`, `requireSessionRole`, `createAuthMiddleware`), and role checks (`authorization.js`)
8
+ - **Database** - `createPrismaClient(ClientClass)` builds a client from your generated Prisma class, plus connection-string/pool helpers, `pingDatabase()`, and `db-instrumentation`
9
+ - **Job Queue** - BullMQ integration (`createQueue`, `createWorker`, `addJob`, queue definitions)
10
+ - **Email** - Provider registry (`EmailProvider`, `registerEmailProvider`), `createEmailService`, and built-in templates
11
+ - **Storage** - Local filesystem built in; S3 and any S3-compatible endpoint (R2, MinIO) via optional AWS SDK install in the app
12
+ - **Metrics** - Prometheus-style registry (`createMetrics`, `metrics`, `httpRequestsTotal`, `jobQueueDepth`, `jobDuration`, and more)
13
+ - **Health** - `runHealthChecks`, `createDefaultProbes`, `checkStorage`, `checkQueues`, `logHealthReport`
14
+ - **Security** - CSRF tokens, rate limiting (in-memory with Redis fallback), file and multipart validation, request logging, error reporting
15
+ - **Errors** - Standardized error types (`ValidationError`, `NotFoundError`, `UnauthorizedError`, `AppError`, and others)
16
+ - **Utilities** - `retryAsync`, `deepMerge`, `randomString`, `sanitizeId`, `measureTime`, `memoize`, query builder, pagination, cache, locale helpers
14
17
  - **Validation** - Zod-based request body validation
18
+ - **Payments and messaging** - Stripe client and webhook helpers, SMS provider
19
+ - **Schema introspection** - `parseSchema`, `getModels`, `getEnums`, `getModelByName`, `coerceId`, `resetSchemaCache`
15
20
  - **Redis / Mail** - Connection helpers for Redis and local mail (Mailpit)
21
+ - **Testing** - factories, mocks, and helpers under `@usequark/quark-core/testing`
16
22
 
17
23
  ## Usage
18
24
 
@@ -34,18 +40,30 @@ All modules are re-exported from the package root. See JSDoc comments on each fu
34
40
 
35
41
  Prefer a subpath when you want a smaller import graph or the browser-safe logger:
36
42
 
43
+ Every subpath in `packages/core/package.json` `exports`, verified against the file:
44
+
37
45
  | Subpath | Contents |
38
46
  |---|---|
47
+ | `@usequark/quark-core` | everything below, re-exported from the root |
39
48
  | `@usequark/quark-core/core` | errors, logger, validation, utils, pagination, csrf, rate limiter, file validation, db |
40
- | `@usequark/quark-core/db` | Prisma singleton, connection string, pool config, `pingDatabase()` |
41
- | `@usequark/quark-core/admin` | Prisma schema introspection helpers |
42
49
  | `@usequark/quark-core/auth` | auth config helpers + session guards |
43
50
  | `@usequark/quark-core/auth/middleware` | session guards only |
51
+ | `@usequark/quark-core/db` | `createPrismaClient`, connection string, pool config, `pingDatabase()` |
52
+ | `@usequark/quark-core/queue` | BullMQ queue and worker helpers |
53
+ | `@usequark/quark-core/email` | `EmailProvider`, `registerEmailProvider`, `createEmailService` |
54
+ | `@usequark/quark-core/errors` | error classes only |
55
+ | `@usequark/quark-core/health` | health check runners and default probes |
56
+ | `@usequark/quark-core/metrics` | metrics registry and default instruments |
44
57
  | `@usequark/quark-core/storage` | local adapter, storage factory, key/URL helpers, `createS3Storage` |
45
58
  | `@usequark/quark-core/storage/s3` | S3 adapter only |
46
- | `@usequark/quark-core/logger` | browser-safe logger |
47
- | `@usequark/quark-core/locale` | locale helpers |
59
+ | `@usequark/quark-core/sms` | SMS provider |
48
60
  | `@usequark/quark-core/stripe` | Stripe client + webhook helpers |
61
+ | `@usequark/quark-core/admin` | Prisma schema introspection helpers |
62
+ | `@usequark/quark-core/locale` | locale helpers |
63
+ | `@usequark/quark-core/logger` | browser-safe logger |
64
+ | `@usequark/quark-core/testing` | test factories, mocks, helpers |
65
+
66
+ `createPrismaClient` is a factory, not a singleton: it takes your generated Prisma `Client` class as its argument so core never depends on your generated client path.
49
67
 
50
68
  `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`, `pg`, and `stripe` are optional peer dependencies. They are only loaded when the matching feature is used, so apps that do not use S3, `pingDatabase()`, or payments do not need them installed.
51
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-core",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
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",
@@ -32,6 +32,7 @@
32
32
  "./admin": "./src/admin.js",
33
33
  "./auth": "./src/auth/index.js",
34
34
  "./auth/middleware": "./src/auth/middleware.js",
35
+ "./csrf-client": "./src/csrf-client.js",
35
36
  "./db": "./src/db.js",
36
37
  "./email": "./src/email.js",
37
38
  "./errors": "./src/errors.js",
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @usequark/quark-core - CSRF Client Helpers
3
+ * Browser-safe companion to `csrf.js`, for the "use client" side of the
4
+ * double-submit handshake.
5
+ *
6
+ * ## Why this is a separate module
7
+ *
8
+ * `csrf.js` imports `node:crypto`. A client component cannot reach this module
9
+ * through the `@usequark/quark-core` barrel without dragging that Node builtin
10
+ * into the browser bundle, which fails the build. This file therefore imports
11
+ * nothing at all, and is published as its own subpath export:
12
+ *
13
+ * ```js
14
+ * import { getCsrfToken } from "@usequark/quark-core/csrf-client";
15
+ * ```
16
+ */
17
+
18
+ /** Endpoint that mints a token and sets the matching cookie. */
19
+ const DEFAULT_ENDPOINT = "/api/csrf";
20
+
21
+ /**
22
+ * Cached tokens are treated as expired after this long.
23
+ *
24
+ * `/api/csrf` writes the cookie with a one-hour `maxAge`. A token cached past
25
+ * that point outlives the cookie it has to match, and the next write fails
26
+ * with "CSRF token not found" — a confusing message for what is really a stale
27
+ * token. Refetching ahead of the cookie's own expiry keeps the two in step.
28
+ */
29
+ const TOKEN_TTL_MS = 50 * 60 * 1000;
30
+
31
+ /** The cached token, and the timestamp at which it stops being usable. */
32
+ let cachedToken = null;
33
+ let cachedUntil = 0;
34
+
35
+ /**
36
+ * The in-flight request, shared so that N callers racing on a cold cache make
37
+ * one HTTP request rather than N. Reset when it settles.
38
+ */
39
+ let inflight = null;
40
+
41
+ /**
42
+ * Bumped by `clearCsrfToken`. A request that started before the clear must not
43
+ * repopulate the cache with the token the caller just threw away.
44
+ */
45
+ let generation = 0;
46
+
47
+ /**
48
+ * Drops any cached token, forcing the next `getCsrfToken` to hit the endpoint.
49
+ *
50
+ * Only needed when the server has rotated the cookie underneath the client — a
51
+ * sign-out that clears cookies, or a 401 from a write that suggests the cached
52
+ * token and the cookie no longer agree. Ordinary expiry needs no call: the
53
+ * cache refetches itself.
54
+ *
55
+ * @returns {void}
56
+ */
57
+ export function clearCsrfToken() {
58
+ generation++;
59
+ cachedToken = null;
60
+ cachedUntil = 0;
61
+ inflight = null;
62
+ }
63
+
64
+ /**
65
+ * Returns a CSRF token, fetching one if the cache is cold or stale.
66
+ *
67
+ * The caller must send the token in the `x-csrf-token` header of a mutating
68
+ * request; `/api/csrf` has also set the matching `httpOnly` cookie, and the
69
+ * server compares the two. Because that cookie is `SameSite=Strict`, a
70
+ * cross-site request never carries it and is rejected before the handler runs —
71
+ * which is why this token has to be obtainable *before* authentication.
72
+ *
73
+ * @param {Object} [options]
74
+ * @param {string} [options.endpoint] - Token endpoint (default `/api/csrf`)
75
+ * @param {() => number} [options.now] - Clock override, for tests
76
+ * @returns {Promise<string>} The token to send as `x-csrf-token`
77
+ * @throws {Error} If the endpoint fails or returns no token
78
+ */
79
+ export async function getCsrfToken(options = {}) {
80
+ const { endpoint = DEFAULT_ENDPOINT, now = Date.now } = options;
81
+
82
+ if (cachedToken && now() < cachedUntil) {
83
+ return cachedToken;
84
+ }
85
+
86
+ if (inflight) {
87
+ return inflight;
88
+ }
89
+
90
+ const startedAt = generation;
91
+
92
+ const request = (async () => {
93
+ const response = await fetch(endpoint, {
94
+ credentials: "same-origin",
95
+ headers: { accept: "application/json" },
96
+ cache: "no-store",
97
+ });
98
+
99
+ if (!response.ok) {
100
+ throw new Error(
101
+ `Could not fetch a CSRF token from ${endpoint}: ${response.status}`,
102
+ );
103
+ }
104
+
105
+ const body = await response.json();
106
+ if (!body?.csrfToken) {
107
+ throw new Error(`${endpoint} did not return a csrfToken`);
108
+ }
109
+
110
+ if (startedAt === generation) {
111
+ cachedToken = body.csrfToken;
112
+ cachedUntil = now() + TOKEN_TTL_MS;
113
+ }
114
+
115
+ return body.csrfToken;
116
+ })();
117
+
118
+ inflight = request;
119
+
120
+ // Both settle paths release the slot. `then(cleanup, cleanup)` rather than
121
+ // `finally`, which would leave the returned promise's rejection unhandled.
122
+ const cleanup = () => {
123
+ if (inflight === request) {
124
+ inflight = null;
125
+ }
126
+ };
127
+ request.then(cleanup, cleanup);
128
+
129
+ return request;
130
+ }
@@ -0,0 +1,200 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, mock, test } from "node:test";
3
+
4
+ import { clearCsrfToken, getCsrfToken } from "./csrf-client.js";
5
+
6
+ // The module reads the global `fetch` at call time and caches in module scope,
7
+ // so every test drives both: a stubbed transport and an explicit clock.
8
+
9
+ // Advances freely; `getCsrfToken` only ever compares against this.
10
+ let clock;
11
+ /** Every request the client made, as `{ endpoint, init }`. */
12
+ let requests;
13
+ /** Queue of responses/errors to hand back, one per fetch. */
14
+ let responses;
15
+
16
+ /** A JSON response shaped like `/api/csrf`. */
17
+ function jsonResponse(body, status = 200) {
18
+ return new Response(JSON.stringify(body), {
19
+ status,
20
+ headers: { "content-type": "application/json" },
21
+ });
22
+ }
23
+
24
+ beforeEach(() => {
25
+ clock = 1_000_000;
26
+ requests = [];
27
+ responses = [];
28
+
29
+ mock.method(globalThis, "fetch", async (endpoint, init) => {
30
+ requests.push({ endpoint, init });
31
+ const next = responses.shift();
32
+ if (!next) {
33
+ throw new Error("test made an unscheduled fetch");
34
+ }
35
+ if (next instanceof Error) throw next;
36
+ return next;
37
+ });
38
+
39
+ clearCsrfToken();
40
+ });
41
+
42
+ afterEach(() => {
43
+ clearCsrfToken();
44
+ mock.restoreAll();
45
+ });
46
+
47
+ test("returns the token the endpoint issued", async () => {
48
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
49
+
50
+ const token = await getCsrfToken({ now: () => clock });
51
+
52
+ assert.equal(token, "token-abc");
53
+ assert.equal(requests.length, 1);
54
+ assert.equal(requests[0].endpoint, "/api/csrf");
55
+ });
56
+
57
+ test("asks for the cookie alongside the token", async () => {
58
+ // The whole scheme is double-submit: the server compares the header against
59
+ // the `csrf_token` cookie. A request that skipped credentials would leave
60
+ // the browser without the cookie and every later write would fail.
61
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
62
+
63
+ await getCsrfToken({ now: () => clock });
64
+
65
+ assert.equal(requests[0].init.credentials, "same-origin");
66
+ assert.equal(requests[0].init.cache, "no-store");
67
+ });
68
+
69
+ test("a second call reuses the cached token instead of refetching", async () => {
70
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
71
+
72
+ const first = await getCsrfToken({ now: () => clock });
73
+ const second = await getCsrfToken({ now: () => clock });
74
+
75
+ assert.equal(second, first);
76
+ assert.equal(requests.length, 1);
77
+ });
78
+
79
+ test("concurrent callers share one request", async () => {
80
+ // Three components mounting at once on a cold cache must not each mint a
81
+ // token; a burst of `/api/csrf` hits is also what a rate limiter would see.
82
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
83
+
84
+ const tokens = await Promise.all([
85
+ getCsrfToken({ now: () => clock }),
86
+ getCsrfToken({ now: () => clock }),
87
+ getCsrfToken({ now: () => clock }),
88
+ ]);
89
+
90
+ assert.deepEqual(tokens, ["token-abc", "token-abc", "token-abc"]);
91
+ assert.equal(requests.length, 1);
92
+ });
93
+
94
+ test("refetches once the cached token has aged out", async () => {
95
+ responses.push(jsonResponse({ csrfToken: "first" }));
96
+ assert.equal(await getCsrfToken({ now: () => clock }), "first");
97
+
98
+ // The cookie this token must match is written with a one-hour maxAge, so a
99
+ // token still being served at that point is a token with no cookie behind it.
100
+ clock += 51 * 60 * 1000;
101
+ responses.push(jsonResponse({ csrfToken: "second" }));
102
+
103
+ assert.equal(await getCsrfToken({ now: () => clock }), "second");
104
+ assert.equal(requests.length, 2);
105
+ });
106
+
107
+ test("keeps serving the cached token right up to its expiry", async () => {
108
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
109
+ await getCsrfToken({ now: () => clock });
110
+
111
+ clock += 49 * 60 * 1000;
112
+ assert.equal(await getCsrfToken({ now: () => clock }), "token-abc");
113
+ assert.equal(requests.length, 1, "refetched well before the cache expired");
114
+ });
115
+
116
+ test("clearCsrfToken forces the next call back to the endpoint", async () => {
117
+ responses.push(jsonResponse({ csrfToken: "first" }));
118
+ await getCsrfToken({ now: () => clock });
119
+
120
+ clearCsrfToken();
121
+ responses.push(jsonResponse({ csrfToken: "second" }));
122
+
123
+ assert.equal(await getCsrfToken({ now: () => clock }), "second");
124
+ assert.equal(requests.length, 2);
125
+ });
126
+
127
+ test("a request already in flight does not resurrect a cleared token", async () => {
128
+ // The clear happens while the fetch is outstanding. Caching its response
129
+ // anyway would hand back the very token the caller discarded — and leave the
130
+ // cache holding a token whose cookie may since have been rotated away.
131
+ let release;
132
+ responses.push(
133
+ new Promise((resolve) => {
134
+ release = () => resolve(jsonResponse({ csrfToken: "stale" }));
135
+ }),
136
+ );
137
+
138
+ const pending = getCsrfToken({ now: () => clock });
139
+ clearCsrfToken();
140
+ release();
141
+ await pending;
142
+
143
+ responses.push(jsonResponse({ csrfToken: "fresh" }));
144
+ assert.equal(await getCsrfToken({ now: () => clock }), "fresh");
145
+ });
146
+
147
+ test("reports the status when the endpoint fails", async () => {
148
+ responses.push(jsonResponse({ error: "Unauthorized" }, 401));
149
+
150
+ await assert.rejects(
151
+ () => getCsrfToken({ now: () => clock }),
152
+ /Could not fetch a CSRF token from \/api\/csrf: 401/,
153
+ );
154
+ });
155
+
156
+ test("rejects a 200 that carries no token", async () => {
157
+ // A proxy that answers 200 with an error page, or a route that changed shape,
158
+ // would otherwise cache `undefined` and fail every later write with a
159
+ // confusing mismatch instead of here.
160
+ responses.push(jsonResponse({ error: "nope" }));
161
+
162
+ await assert.rejects(
163
+ () => getCsrfToken({ now: () => clock }),
164
+ /did not return a csrfToken/,
165
+ );
166
+ });
167
+
168
+ test("a failed fetch does not poison the cache", async () => {
169
+ responses.push(new Error("network down"));
170
+ await assert.rejects(() => getCsrfToken({ now: () => clock }));
171
+
172
+ responses.push(jsonResponse({ csrfToken: "recovered" }));
173
+ assert.equal(await getCsrfToken({ now: () => clock }), "recovered");
174
+ assert.equal(requests.length, 2);
175
+ });
176
+
177
+ test("a failed shared request rejects every waiter, once", async () => {
178
+ responses.push(jsonResponse({ error: "Unauthorized" }, 500));
179
+
180
+ const results = await Promise.allSettled([
181
+ getCsrfToken({ now: () => clock }),
182
+ getCsrfToken({ now: () => clock }),
183
+ ]);
184
+
185
+ assert.deepEqual(
186
+ results.map((r) => r.status),
187
+ ["rejected", "rejected"],
188
+ );
189
+ // The rejection has to be handled on the shared promise's own branch too, or
190
+ // it surfaces as an unhandled rejection rather than at the await site.
191
+ assert.equal(requests.length, 1);
192
+ });
193
+
194
+ test("honours a custom endpoint", async () => {
195
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
196
+
197
+ await getCsrfToken({ endpoint: "/api/csrf-token", now: () => clock });
198
+
199
+ assert.equal(requests[0].endpoint, "/api/csrf-token");
200
+ });
package/src/csrf.js CHANGED
@@ -56,7 +56,14 @@ export function validateCsrfToken(request, sessionToken) {
56
56
  * 2. Compare it against the `X-CSRF-Token` (or `CSRF-Token`) request header
57
57
  * that the client attaches to every mutating request.
58
58
  *
59
- * NextAuth already handles CSRF for /api/auth/* routes, so those are skipped.
59
+ * NextAuth needs no exemption here. It validates its own CSRF token inside
60
+ * `handlers.POST`, and `app/api/auth/[...nextauth]/route.js` exports that
61
+ * handler directly — it is never wrapped in `withCsrfProtection`, so a request
62
+ * to `/api/auth/signin` or `/api/auth/callback/credentials` never reaches this
63
+ * function. The blanket `/api/auth/` skip that used to sit here was therefore
64
+ * protecting nothing, while silently disarming every hand-written route filed
65
+ * under that prefix — `/api/auth/register` included, whose wrapper had never
66
+ * run a check in its life.
60
67
  *
61
68
  * @param {Request} request - Next.js Request object
62
69
  * @returns {void}
@@ -64,17 +71,14 @@ export function validateCsrfToken(request, sessionToken) {
64
71
  */
65
72
  export function requireCsrfToken(request) {
66
73
  const method = request.method;
67
- const path = new URL(request.url).pathname;
68
74
 
69
75
  // Skip CSRF check for:
70
76
  // - Safe methods (GET, HEAD, OPTIONS)
71
- // - NextAuth routes (they have their own CSRF protection)
72
77
  // - Bearer-authenticated requests (e.g. mobile app) - CSRF exploits ambient
73
78
  // cookie credentials; an Authorization header is not sent automatically
74
79
  // by browsers, so cross-site forgery is not possible.
75
80
  if (
76
81
  ["GET", "HEAD", "OPTIONS"].includes(method) ||
77
- path.startsWith("/api/auth/") ||
78
82
  request.headers.get("authorization")?.startsWith("Bearer ")
79
83
  ) {
80
84
  return;
@@ -96,12 +100,29 @@ export function requireCsrfToken(request) {
96
100
  /**
97
101
  * Creates a CSRF-protected API route handler.
98
102
  * Wraps your handler and automatically validates CSRF tokens.
103
+ *
104
+ * A failed check produces a `401` Response rather than a thrown error. The check
105
+ * runs in this wrapper, outside the route handler's own try/catch, so a throw here
106
+ * escapes `handleError` entirely: the client got an unhandled rejection instead of
107
+ * the status code its fetch handling expects. Returning the Response here keeps the
108
+ * rejection path reserved for genuine faults.
109
+ *
99
110
  * @param {Function} handler - Your API route handler
100
111
  * @returns {Function} Wrapped handler with CSRF protection
101
112
  */
102
113
  export function withCsrfProtection(handler) {
103
114
  return async (request, ...args) => {
104
- requireCsrfToken(request);
115
+ try {
116
+ requireCsrfToken(request);
117
+ } catch (error) {
118
+ if (error instanceof UnauthorizedError) {
119
+ return Response.json(error.toJSON(), {
120
+ status: error.statusCode,
121
+ headers: { "Cache-Control": "no-store" },
122
+ });
123
+ }
124
+ throw error;
125
+ }
105
126
  return handler(request, ...args);
106
127
  };
107
128
  }
package/src/csrf.test.js CHANGED
@@ -98,17 +98,58 @@ test("CSRF Module", async (t) => {
98
98
  });
99
99
  });
100
100
 
101
- await t.test("requireCsrfToken skips NextAuth routes", () => {
101
+ await t.test(
102
+ "requireCsrfToken no longer skips /api/auth/ paths",
103
+ async () => {
104
+ // The exemption existed for NextAuth, and it was costing more than it
105
+ // bought: every hand-written route filed under that prefix inherited it.
106
+ // `/api/auth/register` is the one that mattered — its wrapper was inert.
107
+ // NextAuth itself is unaffected because `app/api/auth/[...nextauth]/route.js`
108
+ // exports `handlers.POST` directly and never calls `withCsrfProtection`,
109
+ // so no request to `/api/auth/signin` reaches this function at all.
110
+ const request = mockRequest({
111
+ method: "POST",
112
+ url: "http://localhost/api/auth/register",
113
+ });
114
+
115
+ assert.throws(() => requireCsrfToken(request), /CSRF token not found/);
116
+ },
117
+ );
118
+
119
+ await t.test("a NextAuth callback path is not exempted either", async () => {
120
+ // Guards the reasoning above: if a NextAuth request ever did reach this
121
+ // function, it would be rejected. That is correct, because it cannot
122
+ // happen - and if it ever starts happening, this fails loudly instead of
123
+ // silently relying on an exemption that no longer exists.
102
124
  const request = mockRequest({
103
125
  method: "POST",
104
- url: "http://localhost/api/auth/signin",
126
+ url: "http://localhost/api/auth/callback/credentials",
105
127
  });
106
128
 
107
- assert.doesNotThrow(() => {
108
- requireCsrfToken(request);
109
- });
129
+ assert.throws(() => requireCsrfToken(request), /CSRF token not found/);
110
130
  });
111
131
 
132
+ await t.test(
133
+ "a hand-written /api/auth/ route with a valid token passes",
134
+ async () => {
135
+ // The fix must narrow the exemption, not break the route. A matching
136
+ // cookie/header pair is all `/api/auth/register` ever needed.
137
+ const token = generateCsrfToken();
138
+ const request = mockRequest({
139
+ method: "POST",
140
+ url: "http://localhost/api/auth/register",
141
+ headers: {
142
+ "x-csrf-token": token,
143
+ cookie: `csrf_token=${encodeURIComponent(token)}`,
144
+ },
145
+ });
146
+
147
+ assert.doesNotThrow(() => {
148
+ requireCsrfToken(request);
149
+ });
150
+ },
151
+ );
152
+
112
153
  await t.test("requireCsrfToken skips Bearer-authenticated requests", () => {
113
154
  const request = mockRequest({
114
155
  method: "POST",
@@ -166,4 +207,80 @@ test("CSRF Module", async (t) => {
166
207
  assert.strictEqual(result.body, "success");
167
208
  assert.strictEqual(result.request, request);
168
209
  });
210
+
211
+ await t.test(
212
+ "withCsrfProtection returns 401 instead of throwing",
213
+ async () => {
214
+ // The check runs in the wrapper, outside the route handler's own
215
+ // try/catch, so a throw here escapes `handleError` and reaches the client
216
+ // as an unhandled rejection. The status code is what fetch handling expects.
217
+ const request = mockRequest({
218
+ method: "POST",
219
+ url: "http://localhost/api/posts",
220
+ headers: { "x-csrf-token": generateCsrfToken() },
221
+ });
222
+
223
+ let handlerCalled = false;
224
+ const protectedHandler = withCsrfProtection(async () => {
225
+ handlerCalled = true;
226
+ return { unreachable: true };
227
+ });
228
+
229
+ const response = await protectedHandler(request);
230
+
231
+ assert(response instanceof Response);
232
+ assert.strictEqual(response.status, 401);
233
+ assert.strictEqual(response.headers.get("Cache-Control"), "no-store");
234
+
235
+ const body = await response.json();
236
+ assert.strictEqual(body.code, "UNAUTHORIZED");
237
+ assert.strictEqual(body.statusCode, 401);
238
+ // The handler must not run on a rejected token.
239
+ assert.strictEqual(handlerCalled, false);
240
+ },
241
+ );
242
+
243
+ await t.test(
244
+ "withCsrfProtection returns 401 for a mismatched token",
245
+ async () => {
246
+ const token = generateCsrfToken();
247
+ const request = mockRequest({
248
+ method: "POST",
249
+ url: "http://localhost/api/posts",
250
+ headers: {
251
+ "x-csrf-token": "wrong-token-of-same-len",
252
+ cookie: `csrf_token=${encodeURIComponent(token)}`,
253
+ },
254
+ });
255
+
256
+ const protectedHandler = withCsrfProtection(async () => ({
257
+ unreachable: true,
258
+ }));
259
+ const response = await protectedHandler(request);
260
+
261
+ assert.strictEqual(response.status, 401);
262
+ },
263
+ );
264
+
265
+ await t.test(
266
+ "withCsrfProtection still rejects for a non-CSRF error",
267
+ async () => {
268
+ // Only UnauthorizedError is converted. Anything else from the check is a
269
+ // genuine fault and must keep propagating rather than being reported to the
270
+ // client as an authentication problem.
271
+ const request = mockRequest({ method: "POST" });
272
+ request.headers.get = () => {
273
+ throw new TypeError("headers.get exploded");
274
+ };
275
+
276
+ const protectedHandler = withCsrfProtection(async () => ({
277
+ unreachable: true,
278
+ }));
279
+
280
+ await assert.rejects(
281
+ () => protectedHandler(request),
282
+ /headers.get exploded/,
283
+ );
284
+ },
285
+ );
169
286
  });
package/src/db.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Shared Prisma client singleton, connection-string builder, pool config,
5
5
  * and a lightweight health-check ping. These replace the per-project
6
- * re-implementations in Regina / Pawdora / Trenchmate.
6
+ * re-implementations that every app ends up writing by hand.
7
7
  *
8
8
  * Usage:
9
9
  * import { createPrismaClient, getConnectionString, pingDatabase } from "@usequark/quark-core/db";
@@ -7,6 +7,7 @@ import * as admin from "@usequark/quark-core/admin";
7
7
  import * as auth from "@usequark/quark-core/auth";
8
8
  import * as authMiddleware from "@usequark/quark-core/auth/middleware";
9
9
  import * as coreLight from "@usequark/quark-core/core";
10
+ import * as csrfClient from "@usequark/quark-core/csrf-client";
10
11
  import * as db from "@usequark/quark-core/db";
11
12
  import * as email from "@usequark/quark-core/email";
12
13
  import * as errors from "@usequark/quark-core/errors";
@@ -111,6 +112,42 @@ test("subpath './logger' exports the browser-safe logger", () => {
111
112
  assert.equal(typeof logger.createLogger, "function");
112
113
  });
113
114
 
115
+ test("subpath './csrf-client' exports the browser-safe CSRF client", () => {
116
+ // Importable from a `"use client"` component. It must stay off the barrels
117
+ // (`.` and `./core`) because those re-export `csrf.js`, which imports
118
+ // `node:crypto` — reaching the client helper through either of them would
119
+ // put a Node builtin in the browser bundle and fail the build.
120
+ assert.equal(typeof csrfClient.getCsrfToken, "function");
121
+ assert.equal(typeof csrfClient.clearCsrfToken, "function");
122
+ assert.equal(core.getCsrfToken, undefined);
123
+ assert.equal(coreLight.getCsrfToken, undefined);
124
+ });
125
+
126
+ test("subpath './csrf-client' pulls in no Node builtins", async () => {
127
+ // The hazard is transitive, so checking this module's own source is not
128
+ // enough: assert the whole graph it loads stays free of `node:` imports.
129
+ const loaded = new Set();
130
+ const pending = ["./csrf-client.js"];
131
+
132
+ while (pending.length > 0) {
133
+ const relative = pending.pop();
134
+ const url = new URL(relative, import.meta.url);
135
+ if (loaded.has(url.href)) continue;
136
+ loaded.add(url.href);
137
+
138
+ const source = readFileSync(url, "utf-8");
139
+ assert.doesNotMatch(
140
+ source,
141
+ /from\s+["']node:/,
142
+ `${relative} imports a Node builtin`,
143
+ );
144
+
145
+ for (const match of source.matchAll(/from\s+["'](\.[^"']+)["']/g)) {
146
+ pending.push(new URL(match[1], url).href);
147
+ }
148
+ }
149
+ });
150
+
114
151
  test("subpath './locale' exports locale utilities", () => {
115
152
  assert.equal(typeof locale.getDefaultLocale, "function");
116
153
  assert.equal(typeof locale.getSupportedLocales, "function");