@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,128 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import {
4
+ AppError,
5
+ ConflictError,
6
+ DatabaseError,
7
+ ForbiddenError,
8
+ getErrorMessage,
9
+ getStatusCode,
10
+ logError,
11
+ NotFoundError,
12
+ normalizeError,
13
+ RateLimitError,
14
+ ServiceError,
15
+ UnauthorizedError,
16
+ ValidationError,
17
+ } from "../src/errors.js";
18
+
19
+ test("Error Module", async (t) => {
20
+ await t.test("AppError has correct properties", () => {
21
+ const error = new AppError("Test error", 500, "TEST_ERROR");
22
+ assert(error.message === "Test error");
23
+ assert(error.statusCode === 500);
24
+ assert(error.code === "TEST_ERROR");
25
+ assert(error.name === "AppError");
26
+ });
27
+
28
+ await t.test("AppError.toJSON() serializes correctly", () => {
29
+ const error = new AppError("Test", 500, "TEST");
30
+ const json = error.toJSON();
31
+ assert(json.message === "Test");
32
+ assert(json.statusCode === 500);
33
+ assert(json.code === "TEST");
34
+ assert(json.timestamp);
35
+ });
36
+
37
+ await t.test("ValidationError has status 400", () => {
38
+ const error = new ValidationError("Invalid input");
39
+ assert(error.statusCode === 400);
40
+ assert(error.code === "VALIDATION_ERROR");
41
+ });
42
+
43
+ await t.test("UnauthorizedError has status 401", () => {
44
+ const error = new UnauthorizedError();
45
+ assert(error.statusCode === 401);
46
+ assert(error.code === "UNAUTHORIZED");
47
+ });
48
+
49
+ await t.test("ForbiddenError has status 403", () => {
50
+ const error = new ForbiddenError();
51
+ assert(error.statusCode === 403);
52
+ assert(error.code === "FORBIDDEN");
53
+ });
54
+
55
+ await t.test("NotFoundError has status 404", () => {
56
+ const error = new NotFoundError();
57
+ assert(error.statusCode === 404);
58
+ assert(error.code === "NOT_FOUND");
59
+ });
60
+
61
+ await t.test("ConflictError has status 409", () => {
62
+ const error = new ConflictError();
63
+ assert(error.statusCode === 409);
64
+ assert(error.code === "CONFLICT");
65
+ });
66
+
67
+ await t.test("RateLimitError has status 429", () => {
68
+ const error = new RateLimitError("Too many requests", 60);
69
+ assert(error.statusCode === 429);
70
+ assert(error.code === "RATE_LIMIT");
71
+ assert(error.retryAfter === 60);
72
+ });
73
+
74
+ await t.test("DatabaseError wraps database errors", () => {
75
+ const originalError = new Error("Connection failed");
76
+ const error = new DatabaseError("DB error", originalError);
77
+ assert(error.statusCode === 500);
78
+ assert(error.originalError === originalError);
79
+ });
80
+
81
+ await t.test("ServiceError includes service name", () => {
82
+ const error = new ServiceError("PaymentAPI", "Payment failed");
83
+ assert(error.serviceName === "PaymentAPI");
84
+ assert(error.statusCode === 502);
85
+ const json = error.toJSON();
86
+ assert(json.serviceName === "PaymentAPI");
87
+ });
88
+
89
+ await t.test("getErrorMessage extracts from various types", () => {
90
+ assert(getErrorMessage("string error") === "string error");
91
+ assert(getErrorMessage(new Error("error message")) === "error message");
92
+ assert(getErrorMessage({ message: "obj error" }) === "obj error");
93
+ assert(getErrorMessage({}).includes("unknown")); // Changed from null to empty object
94
+ });
95
+
96
+ await t.test("getStatusCode returns correct code", () => {
97
+ assert(getStatusCode(new ValidationError("")) === 400);
98
+ assert(getStatusCode(new UnauthorizedError()) === 401);
99
+ assert(getStatusCode(new NotFoundError()) === 404);
100
+ assert(getStatusCode(new Error()) === 500);
101
+ });
102
+
103
+ await t.test("normalizeError converts to AppError", () => {
104
+ const error = normalizeError(new Error("test"));
105
+ assert(error instanceof AppError);
106
+ assert(error.message === "test");
107
+ assert(error.statusCode === 500);
108
+ });
109
+
110
+ await t.test("normalizeError handles string errors", () => {
111
+ const error = normalizeError("string error");
112
+ assert(error instanceof AppError);
113
+ assert(error.message === "string error");
114
+ });
115
+
116
+ await t.test("normalizeError handles non-Error objects", () => {
117
+ const error = normalizeError({ some: "object" });
118
+ assert(error instanceof AppError);
119
+ assert(error.message.includes("unknown"));
120
+ });
121
+
122
+ await t.test("logError handles errors with context", () => {
123
+ // This should not throw
124
+ const error = new ValidationError("test");
125
+ logError(error, { userId: "123", action: "test" });
126
+ assert(true);
127
+ });
128
+ });
@@ -0,0 +1,154 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import test from "node:test";
4
+
5
+ import * as core from "@techstream/quark-core";
6
+ import * as admin from "@techstream/quark-core/admin";
7
+ import * as auth from "@techstream/quark-core/auth";
8
+ import * as authMiddleware from "@techstream/quark-core/auth/middleware";
9
+ import * as coreLight from "@techstream/quark-core/core";
10
+ import * as db from "@techstream/quark-core/db";
11
+ import * as email from "@techstream/quark-core/email";
12
+ import * as errors from "@techstream/quark-core/errors";
13
+ import * as locale from "@techstream/quark-core/locale";
14
+ import * as logger from "@techstream/quark-core/logger";
15
+ import * as metrics from "@techstream/quark-core/metrics";
16
+ import * as queue from "@techstream/quark-core/queue";
17
+ import * as sms from "@techstream/quark-core/sms";
18
+ import * as storage from "@techstream/quark-core/storage";
19
+ import * as storageS3 from "@techstream/quark-core/storage/s3";
20
+ import * as stripe from "@techstream/quark-core/stripe";
21
+ import * as testing from "@techstream/quark-core/testing";
22
+
23
+ const packageJson = JSON.parse(
24
+ readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
25
+ );
26
+
27
+ // Guard against accidental subpath removals: every entry in the exports map
28
+ // must resolve. apps/web imports `@techstream/quark-core/logger`, and the
29
+ // scaffolded i18n/payment skills document `./locale` and `./stripe`.
30
+ test("every subpath in package.json exports resolves", async () => {
31
+ for (const subpath of Object.keys(packageJson.exports)) {
32
+ const specifier = `@techstream/quark-core${
33
+ subpath === "." ? "" : subpath.slice(1)
34
+ }`;
35
+ await assert.doesNotReject(
36
+ () => import(specifier),
37
+ `failed to import ${specifier}`,
38
+ );
39
+ }
40
+ });
41
+
42
+ test("subpath '.' (full barrel) exports all key utilities", () => {
43
+ assert.equal(typeof core.createAuthConfig, "function");
44
+ assert.equal(typeof core.createStorage, "function");
45
+ assert.equal(typeof core.AppError, "function");
46
+ assert.equal(typeof core.createLogger, "function");
47
+ assert.equal(typeof core.createS3Storage, "function");
48
+ assert.equal(typeof core.createPrismaClient, "function");
49
+ assert.equal(typeof core.pingDatabase, "function");
50
+ assert.equal(typeof core.parseSchema, "function");
51
+ assert.equal(typeof core.requireSession, "function");
52
+ assert.equal(typeof core.requireSessionRole, "function");
53
+ assert.equal(typeof core.createAuthMiddleware, "function");
54
+ });
55
+
56
+ test("subpath './core' exports lightweight utilities", () => {
57
+ assert.equal(typeof coreLight.AppError, "function");
58
+ assert.equal(typeof coreLight.ValidationError, "function");
59
+ assert.equal(typeof coreLight.createLogger, "function");
60
+ assert.equal(typeof coreLight.validateBody, "function");
61
+ assert.equal(typeof coreLight.retryAsync, "function");
62
+ assert.equal(typeof coreLight.parsePagination, "function");
63
+ assert.equal(typeof coreLight.generateCsrfToken, "function");
64
+ assert.equal(typeof coreLight.createRateLimiter, "function");
65
+ assert.equal(typeof coreLight.validateFile, "function");
66
+ assert.equal(typeof coreLight.createPrismaClient, "function");
67
+ assert.equal(typeof coreLight.pingDatabase, "function");
68
+ // Should NOT export heavier modules
69
+ assert.equal(coreLight.createStorage, undefined);
70
+ assert.equal(coreLight.createEmailService, undefined);
71
+ });
72
+
73
+ test("subpath './auth' exports config + middleware", () => {
74
+ assert.equal(typeof auth.createAuthConfig, "function");
75
+ assert.equal(typeof auth.requireSession, "function");
76
+ assert.equal(typeof auth.requireSessionRole, "function");
77
+ assert.equal(typeof auth.createAuthMiddleware, "function");
78
+ });
79
+
80
+ test("subpath './auth/middleware' exports middleware only", () => {
81
+ assert.equal(typeof authMiddleware.requireSession, "function");
82
+ assert.equal(typeof authMiddleware.requireSessionRole, "function");
83
+ assert.equal(typeof authMiddleware.createAuthMiddleware, "function");
84
+ });
85
+
86
+ test("subpath './errors' exports error classes", () => {
87
+ assert.equal(typeof errors.AppError, "function");
88
+ assert.equal(typeof errors.ValidationError, "function");
89
+ assert.equal(typeof errors.UnauthorizedError, "function");
90
+ assert.equal(typeof errors.ForbiddenError, "function");
91
+ });
92
+
93
+ test("subpath './logger' exports the browser-safe logger", () => {
94
+ assert.equal(typeof logger.createLogger, "function");
95
+ });
96
+
97
+ test("subpath './locale' exports locale utilities", () => {
98
+ assert.equal(typeof locale.getDefaultLocale, "function");
99
+ assert.equal(typeof locale.getSupportedLocales, "function");
100
+ assert.equal(typeof locale.isLocaleSupported, "function");
101
+ });
102
+
103
+ test("subpath './stripe' exports payment utilities", () => {
104
+ assert.equal(typeof stripe.createStripeClient, "function");
105
+ assert.equal(typeof stripe.getStripeWebhookEvent, "function");
106
+ });
107
+
108
+ test("subpath './storage' exports storage utilities", () => {
109
+ assert.equal(typeof storage.createStorage, "function");
110
+ assert.equal(typeof storage.createLocalStorage, "function");
111
+ assert.equal(typeof storage.createS3Storage, "function");
112
+ assert.equal(typeof storage.generateStorageKey, "function");
113
+ assert.equal(typeof storage.getAssetUrl, "function");
114
+ });
115
+
116
+ test("subpath './storage/s3' exports S3 adapter only", () => {
117
+ assert.equal(typeof storageS3.createS3Storage, "function");
118
+ });
119
+
120
+ test("subpath './db' exports database utilities", () => {
121
+ assert.equal(typeof db.getConnectionString, "function");
122
+ assert.equal(typeof db.getPoolConfig, "function");
123
+ assert.equal(typeof db.createPrismaClient, "function");
124
+ assert.equal(typeof db.pingDatabase, "function");
125
+ });
126
+
127
+ test("subpath './admin' exports introspection utilities", () => {
128
+ assert.equal(typeof admin.parseSchema, "function");
129
+ assert.equal(typeof admin.getModelByName, "function");
130
+ assert.equal(typeof admin.detectIdField, "function");
131
+ assert.equal(typeof admin.coerceId, "function");
132
+ assert.equal(typeof admin.getModels, "function");
133
+ assert.equal(typeof admin.getEnums, "function");
134
+ });
135
+
136
+ test("subpath './queue' exports queue utilities", () => {
137
+ assert.equal(typeof queue.createQueue, "function");
138
+ });
139
+
140
+ test("subpath './email' exports email utilities", () => {
141
+ assert.equal(typeof email.createEmailService, "function");
142
+ });
143
+
144
+ test("subpath './sms' exports SMS utilities", () => {
145
+ assert.equal(typeof sms.createSmsService, "function");
146
+ });
147
+
148
+ test("subpath './metrics' exports metrics utilities", () => {
149
+ assert.equal(typeof metrics.createMetrics, "function");
150
+ });
151
+
152
+ test("subpath './testing' exports test utilities", () => {
153
+ assert.equal(typeof testing.createTestUser, "function");
154
+ });
@@ -0,0 +1,249 @@
1
+ /**
2
+ * @techstream/quark-core - File Validation
3
+ *
4
+ * Validates uploaded files: size limits, MIME type allow-lists,
5
+ * and magic-byte verification to prevent MIME spoofing.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Magic bytes for common file types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const MAGIC_BYTES = [
13
+ // Images
14
+ { mime: "image/jpeg", bytes: [0xff, 0xd8, 0xff] },
15
+ { mime: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47] },
16
+ { mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] },
17
+ {
18
+ mime: "image/webp",
19
+ bytes: [0x52, 0x49, 0x46, 0x46],
20
+ offset: 0,
21
+ extra: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
22
+ },
23
+ {
24
+ mime: "image/svg+xml",
25
+ bytes: null,
26
+ detect: (buf) => {
27
+ const head = buf.subarray(0, 256).toString("utf8").trimStart();
28
+ return head.startsWith("<svg") || head.startsWith("<?xml");
29
+ },
30
+ },
31
+ {
32
+ mime: "image/avif",
33
+ bytes: null,
34
+ detect: (buf) => {
35
+ // ftyp box with "avif" or "avis" brand
36
+ if (buf.length < 12) return false;
37
+ const ftyp = buf.subarray(4, 8).toString("ascii");
38
+ if (ftyp !== "ftyp") return false;
39
+ const brand = buf.subarray(8, 12).toString("ascii");
40
+ return brand === "avif" || brand === "avis";
41
+ },
42
+ },
43
+
44
+ // Documents
45
+ { mime: "application/pdf", bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF
46
+ { mime: "application/zip", bytes: [0x50, 0x4b, 0x03, 0x04] },
47
+
48
+ // Video - MP4 uses ftyp box like AVIF, but with different brands (isom, mp41, mp42, etc.)
49
+ {
50
+ mime: "video/mp4",
51
+ bytes: null,
52
+ detect: (buf) => {
53
+ if (buf.length < 12) return false;
54
+ const ftyp = buf.subarray(4, 8).toString("ascii");
55
+ if (ftyp !== "ftyp") return false;
56
+ // Exclude AVIF brands already handled above
57
+ const brand = buf.subarray(8, 12).toString("ascii");
58
+ if (brand === "avif" || brand === "avis") return false;
59
+ return true;
60
+ },
61
+ },
62
+ { mime: "video/webm", bytes: [0x1a, 0x45, 0xdf, 0xa3] },
63
+ ];
64
+
65
+ /**
66
+ * Detect MIME type from file buffer using magic bytes.
67
+ * Returns null if no match found.
68
+ * @param {Buffer} buffer - At least the first 256 bytes of the file
69
+ * @returns {string | null}
70
+ */
71
+ export function detectMimeType(buffer) {
72
+ for (const entry of MAGIC_BYTES) {
73
+ if (entry.detect) {
74
+ if (entry.detect(buffer)) return entry.mime;
75
+ continue;
76
+ }
77
+
78
+ if (!entry.bytes) continue;
79
+
80
+ const offset = entry.offset || 0;
81
+ let match = true;
82
+ for (let i = 0; i < entry.bytes.length; i++) {
83
+ if (buffer[offset + i] !== entry.bytes[i]) {
84
+ match = false;
85
+ break;
86
+ }
87
+ }
88
+
89
+ if (match && entry.extra) {
90
+ for (let i = 0; i < entry.extra.bytes.length; i++) {
91
+ if (buffer[entry.extra.offset + i] !== entry.extra.bytes[i]) {
92
+ match = false;
93
+ break;
94
+ }
95
+ }
96
+ }
97
+
98
+ if (match) return entry.mime;
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Defaults
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const DEFAULT_MAX_SIZE = 10 * 1024 * 1024; // 10 MB
109
+
110
+ const DEFAULT_ALLOWED_TYPES = [
111
+ "image/jpeg",
112
+ "image/png",
113
+ "image/gif",
114
+ "image/webp",
115
+ "image/avif",
116
+ "image/svg+xml",
117
+ "application/pdf",
118
+ ];
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Validation
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * @typedef {Object} FileValidationOptions
126
+ * @property {number} [maxSize] - Max file size in bytes (default: 10MB)
127
+ * @property {string[]} [allowedTypes] - Allowed MIME types. Supports wildcards like "image/*"
128
+ * @property {boolean} [verifyMagicBytes=true] - Check magic bytes match declared MIME type
129
+ */
130
+
131
+ /**
132
+ * @typedef {Object} FileValidationResult
133
+ * @property {boolean} valid
134
+ * @property {string} [error] - Human-readable error message (when invalid)
135
+ * @property {string} [detectedType] - MIME type detected from magic bytes
136
+ */
137
+
138
+ /**
139
+ * Validate a file against size, type and magic-byte rules.
140
+ *
141
+ * @param {Object} file
142
+ * @param {string} file.filename - Original filename
143
+ * @param {string} file.mimeType - Declared MIME type (from Content-Type)
144
+ * @param {number} file.size - File size in bytes
145
+ * @param {Buffer} [file.buffer] - File content (required if verifyMagicBytes is true)
146
+ * @param {FileValidationOptions} [options]
147
+ * @returns {FileValidationResult}
148
+ */
149
+ export function validateFile(file, options = {}) {
150
+ const {
151
+ maxSize = parseMaxSize(),
152
+ allowedTypes = parseAllowedTypes(),
153
+ verifyMagicBytes = true,
154
+ } = options;
155
+
156
+ // Size check
157
+ if (file.size > maxSize) {
158
+ return {
159
+ valid: false,
160
+ error: `File exceeds maximum size of ${formatBytes(maxSize)} (got ${formatBytes(file.size)})`,
161
+ };
162
+ }
163
+
164
+ // MIME type allow-list
165
+ if (!isTypeAllowed(file.mimeType, allowedTypes)) {
166
+ return {
167
+ valid: false,
168
+ error: `File type "${file.mimeType}" is not allowed. Allowed: ${allowedTypes.join(", ")}`,
169
+ };
170
+ }
171
+
172
+ // Magic byte verification
173
+ if (verifyMagicBytes && file.buffer) {
174
+ const detected = detectMimeType(file.buffer);
175
+ if (detected && !isSameCategory(detected, file.mimeType)) {
176
+ return {
177
+ valid: false,
178
+ error: `File content does not match declared type. Declared: ${file.mimeType}, detected: ${detected}`,
179
+ detectedType: detected,
180
+ };
181
+ }
182
+ return { valid: true, detectedType: detected };
183
+ }
184
+
185
+ return { valid: true };
186
+ }
187
+
188
+ /**
189
+ * Check if a MIME type matches an allow list (supports wildcards like "image/*").
190
+ * @param {string} mimeType
191
+ * @param {string[]} allowedTypes
192
+ * @returns {boolean}
193
+ */
194
+ export function isTypeAllowed(mimeType, allowedTypes) {
195
+ if (!mimeType) return false;
196
+ const normalized = mimeType.toLowerCase().trim();
197
+
198
+ for (const pattern of allowedTypes) {
199
+ const p = pattern.toLowerCase().trim();
200
+ if (p === normalized) return true;
201
+ if (p === "*/*") return true;
202
+ if (p.endsWith("/*")) {
203
+ const category = p.slice(0, p.indexOf("/"));
204
+ if (normalized.startsWith(`${category}/`)) return true;
205
+ }
206
+ }
207
+
208
+ return false;
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Helpers
213
+ // ---------------------------------------------------------------------------
214
+
215
+ function parseMaxSize() {
216
+ const env = process.env.UPLOAD_MAX_SIZE;
217
+ if (!env) return DEFAULT_MAX_SIZE;
218
+ const n = Number(env);
219
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_SIZE;
220
+ }
221
+
222
+ function parseAllowedTypes() {
223
+ const env = process.env.UPLOAD_ALLOWED_TYPES;
224
+ if (!env) return DEFAULT_ALLOWED_TYPES;
225
+ return env
226
+ .split(",")
227
+ .map((t) => t.trim())
228
+ .filter(Boolean);
229
+ }
230
+
231
+ function formatBytes(bytes) {
232
+ if (bytes < 1024) return `${bytes} B`;
233
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
234
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
235
+ }
236
+
237
+ /**
238
+ * Check if two MIME types belong to the same top-level category.
239
+ * e.g. "image/jpeg" and "image/png" → true
240
+ * @param {string} a
241
+ * @param {string} b
242
+ * @returns {boolean}
243
+ */
244
+ function isSameCategory(a, b) {
245
+ if (!a || !b) return true; // lenient when one side is unknown
246
+ const catA = a.split("/")[0];
247
+ const catB = b.split("/")[0];
248
+ return catA === catB;
249
+ }