@usequark/quark-core 2.5.4

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.
Files changed (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * @techstream/quark-core - S3 Storage Adapter
3
+ *
4
+ * Kept in its own module so `storage.js` (and the main barrel) never pull
5
+ * the AWS SDK onto the import path: `@aws-sdk/client-s3` and
6
+ * `@aws-sdk/s3-request-presigner` are optional peer dependencies and are
7
+ * only loaded when an S3 adapter method is actually called. Apps running
8
+ * `STORAGE_PROVIDER=local` do not need them installed.
9
+ *
10
+ * The dynamic imports are marked `webpackIgnore` so bundlers leave them as
11
+ * runtime imports (resolved from the app's `node_modules`) instead of
12
+ * trying to resolve them at build time. Apps that use S3 must install both
13
+ * packages — the scaffolded web app already lists them as dependencies.
14
+ */
15
+
16
+ const S3_CLIENT_PACKAGE = "@aws-sdk/client-s3";
17
+ const S3_PRESIGNER_PACKAGE = "@aws-sdk/s3-request-presigner";
18
+
19
+ function isMissingPackageError(error, packageName) {
20
+ const message = error?.message ?? "";
21
+ return (
22
+ error?.code === "ERR_MODULE_NOT_FOUND" ||
23
+ error?.code === "MODULE_NOT_FOUND" ||
24
+ message.includes(`Cannot find package '${packageName}'`) ||
25
+ message.includes(`Cannot find module '${packageName}'`)
26
+ );
27
+ }
28
+
29
+ async function importS3ClientPackage() {
30
+ try {
31
+ return await import(/* webpackIgnore: true */ "@aws-sdk/client-s3");
32
+ } catch (error) {
33
+ if (isMissingPackageError(error, S3_CLIENT_PACKAGE)) {
34
+ throw new Error(
35
+ 'S3 storage support requires installing "@aws-sdk/client-s3" in the app that uses @techstream/quark-core/storage/s3.',
36
+ );
37
+ }
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async function importS3PresignerPackage() {
43
+ try {
44
+ return await import(
45
+ /* webpackIgnore: true */ "@aws-sdk/s3-request-presigner"
46
+ );
47
+ } catch (error) {
48
+ if (isMissingPackageError(error, S3_PRESIGNER_PACKAGE)) {
49
+ throw new Error(
50
+ 'Signed upload URLs require installing "@aws-sdk/s3-request-presigner" in the app that uses @techstream/quark-core/storage/s3.',
51
+ );
52
+ }
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ // Cache the in-flight module promises so repeated adapter calls do not
58
+ // re-run the dynamic import (and a failed import keeps reporting the same
59
+ // friendly error).
60
+ let _s3ClientPackage = null;
61
+ let _s3PresignerPackage = null;
62
+
63
+ function loadS3ClientPackage() {
64
+ _s3ClientPackage ??= importS3ClientPackage();
65
+ return _s3ClientPackage;
66
+ }
67
+
68
+ function loadS3PresignerPackage() {
69
+ _s3PresignerPackage ??= importS3PresignerPackage();
70
+ return _s3PresignerPackage;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // S3-Compatible Storage Adapter (AWS S3 / Cloudflare R2 / MinIO)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Creates an S3-compatible storage adapter.
79
+ * Works with AWS S3, Cloudflare R2, MinIO, and any S3-compatible service.
80
+ *
81
+ * Required env vars (or pass via options):
82
+ * S3_BUCKET - Bucket name
83
+ * S3_REGION - Region (use "auto" for R2)
84
+ * S3_ACCESS_KEY_ID - Access key
85
+ * S3_SECRET_ACCESS_KEY - Secret key
86
+ * S3_ENDPOINT - Custom endpoint (required for R2/MinIO)
87
+ * S3_PUBLIC_URL - Optional public URL prefix for signed/public URLs
88
+ *
89
+ * For Cloudflare R2:
90
+ * S3_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
91
+ * S3_REGION=auto
92
+ *
93
+ * Requires `@aws-sdk/client-s3` (and `@aws-sdk/s3-request-presigner` for
94
+ * `getSignedUploadUrl`) to be installed in the consuming app. Both are
95
+ * loaded lazily on first use, so creating an adapter and reading its
96
+ * `provider` / `getPublicUrl()` works without them.
97
+ *
98
+ * @param {Object} options
99
+ * @returns {StorageAdapter}
100
+ */
101
+ export function createS3Storage(options = {}) {
102
+ const bucket = options.bucket || process.env.S3_BUCKET;
103
+ const region = options.region || process.env.S3_REGION || "auto";
104
+ const endpoint = options.endpoint || process.env.S3_ENDPOINT;
105
+ const accessKeyId = options.accessKeyId || process.env.S3_ACCESS_KEY_ID;
106
+ const secretAccessKey =
107
+ options.secretAccessKey || process.env.S3_SECRET_ACCESS_KEY;
108
+ const publicUrl = options.publicUrl || process.env.S3_PUBLIC_URL;
109
+
110
+ if (!bucket) throw new Error("S3_BUCKET is required for S3 storage");
111
+ if (!accessKeyId)
112
+ throw new Error("S3_ACCESS_KEY_ID is required for S3 storage");
113
+ if (!secretAccessKey)
114
+ throw new Error("S3_SECRET_ACCESS_KEY is required for S3 storage");
115
+
116
+ /** @type {import("@aws-sdk/client-s3").S3Client | null} */
117
+ let _client = null;
118
+
119
+ async function getClient() {
120
+ if (_client) return _client;
121
+ const { S3Client } = await loadS3ClientPackage();
122
+ _client = new S3Client({
123
+ region,
124
+ credentials: { accessKeyId, secretAccessKey },
125
+ ...(endpoint ? { endpoint, forcePathStyle: true } : {}),
126
+ });
127
+ return _client;
128
+ }
129
+
130
+ return {
131
+ provider: "s3",
132
+
133
+ async put(key, data, meta = {}) {
134
+ let body;
135
+ if (Buffer.isBuffer(data) || typeof data === "string") {
136
+ body = data;
137
+ } else if (data && typeof data.pipe === "function") {
138
+ const chunks = [];
139
+ for await (const chunk of data) {
140
+ chunks.push(chunk);
141
+ }
142
+ body = Buffer.concat(chunks);
143
+ } else {
144
+ throw new Error("put() expects a Buffer, string, or Readable stream");
145
+ }
146
+
147
+ const { PutObjectCommand } = await loadS3ClientPackage();
148
+ const client = await getClient();
149
+ await client.send(
150
+ new PutObjectCommand({
151
+ Bucket: bucket,
152
+ Key: key,
153
+ Body: body,
154
+ ContentType: meta.contentType || "application/octet-stream",
155
+ ...(meta.cacheControl ? { CacheControl: meta.cacheControl } : {}),
156
+ }),
157
+ );
158
+
159
+ return { key, provider: "s3" };
160
+ },
161
+
162
+ async get(key) {
163
+ const { GetObjectCommand } = await loadS3ClientPackage();
164
+ const client = await getClient();
165
+ const response = await client.send(
166
+ new GetObjectCommand({ Bucket: bucket, Key: key }),
167
+ );
168
+
169
+ const chunks = [];
170
+ for await (const chunk of response.Body) {
171
+ chunks.push(chunk);
172
+ }
173
+
174
+ return {
175
+ body: Buffer.concat(chunks),
176
+ contentType: response.ContentType || null,
177
+ };
178
+ },
179
+
180
+ async delete(key) {
181
+ const { DeleteObjectCommand } = await loadS3ClientPackage();
182
+ const client = await getClient();
183
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
184
+ },
185
+
186
+ async exists(key) {
187
+ const { HeadObjectCommand } = await loadS3ClientPackage();
188
+ try {
189
+ const client = await getClient();
190
+ await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
191
+ return true;
192
+ } catch (err) {
193
+ if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
194
+ return false;
195
+ }
196
+ throw err;
197
+ }
198
+ },
199
+
200
+ getPublicUrl(key) {
201
+ if (publicUrl) {
202
+ return `${publicUrl.replace(/\/$/, "")}/${key}`;
203
+ }
204
+ return `/api/files/${encodeURIComponent(key)}`;
205
+ },
206
+
207
+ /**
208
+ * Generate a pre-signed URL that allows a client (e.g. the browser) to
209
+ * upload directly to S3/R2 without routing the binary through the server.
210
+ *
211
+ * @param {string} key - Storage key for the object to be uploaded
212
+ * @param {Object} [options]
213
+ * @param {number} [options.expiresIn=300] - URL validity in seconds (default: 5 min)
214
+ * @param {string} [options.contentType] - Expected Content-Type; enforced by S3
215
+ * @returns {Promise<{ url: string, key: string, expiresAt: string }>}
216
+ */
217
+ async getSignedUploadUrl(key, options = {}) {
218
+ const expiresIn = options.expiresIn ?? 300;
219
+
220
+ const { PutObjectCommand } = await loadS3ClientPackage();
221
+ const { getSignedUrl } = await loadS3PresignerPackage();
222
+
223
+ const command = new PutObjectCommand({
224
+ Bucket: bucket,
225
+ Key: key,
226
+ ...(options.contentType ? { ContentType: options.contentType } : {}),
227
+ });
228
+
229
+ const client = await getClient();
230
+ const url = await getSignedUrl(client, command, { expiresIn });
231
+ const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
232
+
233
+ return { url, key, expiresAt };
234
+ },
235
+ };
236
+ }
package/src/storage.js ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @techstream/quark-core - File Storage
3
+ *
4
+ * Adapter-based storage abstraction supporting local filesystem and
5
+ * S3-compatible providers (AWS S3, Cloudflare R2, MinIO, etc.).
6
+ *
7
+ * The S3 adapter lives in its own module (storage-s3.js) and loads the
8
+ * AWS SDK lazily, so importing this file — or the main barrel — never
9
+ * requires `@aws-sdk/client-s3` / `@aws-sdk/s3-request-presigner` to be
10
+ * installed. Apps using `STORAGE_PROVIDER=s3` must install them (the
11
+ * scaffolded web app declares both as dependencies).
12
+ *
13
+ * Usage:
14
+ * const storage = createStorage(); // reads STORAGE_PROVIDER env
15
+ * await storage.put(key, buffer, { contentType: "image/png" });
16
+ * const { body, contentType } = await storage.get(key);
17
+ * await storage.delete(key);
18
+ */
19
+
20
+ import { createWriteStream } from "node:fs";
21
+ import { mkdir, readFile, stat, unlink } from "node:fs/promises";
22
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
23
+ import { pipeline } from "node:stream/promises";
24
+
25
+ // Import + re-export the S3 adapter so existing deep-imports continue
26
+ // to work (`import { createS3Storage } from "@techstream/quark-core/storage"`).
27
+ // storage-s3.js has no top-level AWS SDK imports, so this stays safe for
28
+ // local-only deployments that never installed the optional peer deps.
29
+ import { createS3Storage } from "./storage-s3.js";
30
+
31
+ export { createS3Storage };
32
+
33
+ /**
34
+ * Resolves a storage key against the base directory and guards against
35
+ * path-traversal attacks. Throws if the resolved path escapes baseDir.
36
+ *
37
+ * @param {string} baseDir - Absolute base directory
38
+ * @param {string} key - Caller-supplied storage key
39
+ * @returns {string} Absolute, validated file path
40
+ */
41
+ function safePath(baseDir, key) {
42
+ const resolved = resolve(baseDir, key);
43
+ const relativePath = relative(baseDir, resolved);
44
+ if (
45
+ relativePath === ".." ||
46
+ relativePath.startsWith(`..${sep}`) ||
47
+ isAbsolute(relativePath)
48
+ ) {
49
+ throw new Error("Path traversal detected - key escapes storage directory");
50
+ }
51
+ return resolved;
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Local Storage Adapter
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Creates a local-filesystem storage adapter.
60
+ * @param {Object} options
61
+ * @param {string} [options.directory="./uploads"] - Base directory for files
62
+ * @returns {StorageAdapter}
63
+ */
64
+ export function createLocalStorage(options = {}) {
65
+ const baseDir = resolve(
66
+ options.directory || process.env.STORAGE_LOCAL_DIR || "./uploads",
67
+ );
68
+
69
+ return {
70
+ provider: "local",
71
+
72
+ async put(key, data, _meta = {}) {
73
+ const filePath = safePath(baseDir, key);
74
+ await mkdir(dirname(filePath), { recursive: true });
75
+
76
+ if (Buffer.isBuffer(data) || typeof data === "string") {
77
+ const ws = createWriteStream(filePath);
78
+ ws.end(data);
79
+ await new Promise((res, rej) => {
80
+ ws.on("finish", res);
81
+ ws.on("error", rej);
82
+ });
83
+ } else if (data && typeof data.pipe === "function") {
84
+ await pipeline(data, createWriteStream(filePath));
85
+ } else {
86
+ throw new Error("put() expects a Buffer, string, or Readable stream");
87
+ }
88
+
89
+ return { key, provider: "local" };
90
+ },
91
+
92
+ async get(key) {
93
+ const filePath = safePath(baseDir, key);
94
+ const buffer = await readFile(filePath);
95
+ return { body: buffer, contentType: null };
96
+ },
97
+
98
+ async delete(key) {
99
+ const filePath = safePath(baseDir, key);
100
+ try {
101
+ await unlink(filePath);
102
+ } catch (err) {
103
+ if (err.code !== "ENOENT") throw err;
104
+ }
105
+ },
106
+
107
+ async exists(key) {
108
+ const filePath = safePath(baseDir, key);
109
+ try {
110
+ await stat(filePath);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ },
116
+
117
+ getPublicUrl(key) {
118
+ // Local files are served via the API route, not a public URL
119
+ return `/api/files/${encodeURIComponent(key)}`;
120
+ },
121
+
122
+ async getSignedUploadUrl(_key, _options = {}) {
123
+ throw new Error(
124
+ "getSignedUploadUrl() requires STORAGE_PROVIDER=s3. " +
125
+ "For local development, upload files via POST /api/files instead.",
126
+ );
127
+ },
128
+ };
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Factory
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Creates a storage adapter based on STORAGE_PROVIDER env var.
137
+ * "local" (default) → local filesystem
138
+ * "s3" → S3-compatible (AWS, R2, MinIO)
139
+ *
140
+ * The S3 adapter is imported from storage-s3.js, which defers the AWS SDK
141
+ * import to the first adapter call. Local-only apps therefore never need
142
+ * the optional `@aws-sdk/*` peer dependencies installed.
143
+ *
144
+ * @param {Object} [options] - Override options passed to the adapter
145
+ * @returns {StorageAdapter}
146
+ */
147
+ export function createStorage(options = {}) {
148
+ const provider = options.provider || process.env.STORAGE_PROVIDER || "local";
149
+
150
+ switch (provider) {
151
+ case "s3":
152
+ return createS3Storage(options);
153
+ case "local":
154
+ return createLocalStorage(options);
155
+ default:
156
+ throw new Error(
157
+ `Unknown STORAGE_PROVIDER: "${provider}". Supported: "local", "s3"`,
158
+ );
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Generate a unique storage key for a file upload.
164
+ * Format: <prefix>/<year>/<month>/<randomId>-<sanitizedFilename>
165
+ *
166
+ * @param {string} originalFilename
167
+ * @param {Object} [options]
168
+ * @param {string} [options.prefix="uploads"] - Key prefix / folder
169
+ * @returns {string}
170
+ */
171
+ export function generateStorageKey(originalFilename, options = {}) {
172
+ const prefix = options.prefix || "uploads";
173
+ const now = new Date();
174
+ const year = now.getFullYear();
175
+ const month = String(now.getMonth() + 1).padStart(2, "0");
176
+ const id = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
177
+ const safeName = originalFilename
178
+ .replace(/[^a-zA-Z0-9._-]/g, "_")
179
+ .replace(/_{2,}/g, "_")
180
+ .toLowerCase();
181
+
182
+ return `${prefix}/${year}/${month}/${id}-${safeName}`;
183
+ }
184
+
185
+ /**
186
+ * Returns the public URL for a stored asset.
187
+ *
188
+ * Checks `ASSET_CDN_URL` first - if set, prepends it to the key.
189
+ * This is provider-agnostic: works with any CDN (CloudFront, Cloudflare,
190
+ * Bunny, Fastly, etc.) as long as the CDN is pointed at the same bucket.
191
+ *
192
+ * Falls back to the local API route (`/api/files/<key>`) when no CDN is
193
+ * configured - covers local development and any environment where
194
+ * `STORAGE_PROVIDER=local` is used.
195
+ *
196
+ * @param {string} key - Storage key (e.g. "uploads/2026/02/abc-photo.jpg")
197
+ * @returns {string} Full CDN URL or local API route
198
+ */
199
+ export function getAssetUrl(key) {
200
+ const cdnBase = process.env.ASSET_CDN_URL;
201
+ if (cdnBase) return `${cdnBase.replace(/\/$/, "")}/${key}`;
202
+ return `/api/files/${encodeURIComponent(key)}`;
203
+ }
204
+
205
+ /**
206
+ * @typedef {Object} StorageAdapter
207
+ * @property {"local" | "s3"} provider
208
+ * @property {(key: string, data: Buffer | Readable | string, meta?: { contentType?: string, cacheControl?: string }) => Promise<{ key: string, provider: string }>} put
209
+ * @property {(key: string) => Promise<{ body: Buffer, contentType: string | null }>} get
210
+ * @property {(key: string) => Promise<void>} delete
211
+ * @property {(key: string) => Promise<boolean>} exists
212
+ * @property {(key: string) => string} getPublicUrl
213
+ * @property {(key: string, options?: { expiresIn?: number, contentType?: string }) => Promise<{ url: string, key: string, expiresAt: string }>} getSignedUploadUrl
214
+ */