@momentumcms/server-core 0.1.10 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## 0.3.0 (2026-02-20)
2
+
3
+ ### 🚀 Features
4
+
5
+ - add article slugs, detail pages, live preview, and fix PATCH field hooks ([454b61c](https://github.com/DonaldMurillo/momentum-cms/commit/454b61c))
6
+ - add named tabs support with nested data grouping and UI improvements ([#30](https://github.com/DonaldMurillo/momentum-cms/pull/30))
7
+
8
+ ### 🩹 Fixes
9
+
10
+ - address code review issues across admin, server-core, and e2e ([4664463](https://github.com/DonaldMurillo/momentum-cms/commit/4664463))
11
+ - add auth guard and MIME validation to PATCH upload route; fix pagination with client-side filtering ([#32](https://github.com/DonaldMurillo/momentum-cms/pull/32))
12
+
13
+ ### ❤️ Thank You
14
+
15
+ - Claude Opus 4.6
16
+ - Donald Murillo @DonaldMurillo
17
+
18
+ ## 0.2.0 (2026-02-17)
19
+
20
+ ### 🚀 Features
21
+
22
+ - add named tabs support with nested data grouping and tab UI improvements ([63ab63e](https://github.com/DonaldMurillo/momentum-cms/commit/63ab63e))
23
+
24
+ ### ❤️ Thank You
25
+
26
+ - Claude Opus 4.6
27
+ - Donald Murillo @DonaldMurillo
28
+
1
29
  ## 0.1.10 (2026-02-17)
2
30
 
3
31
  ### 🩹 Fixes
package/index.cjs CHANGED
@@ -54,6 +54,7 @@ __export(src_exports, {
54
54
  getMomentumAPI: () => getMomentumAPI,
55
55
  getSwaggerUIHTML: () => getSwaggerUIHTML,
56
56
  getUploadConfig: () => getUploadConfig,
57
+ handleCollectionUpload: () => handleCollectionUpload,
57
58
  handleFileDelete: () => handleFileDelete,
58
59
  handleFileGet: () => handleFileGet,
59
60
  handleUpload: () => handleUpload,
@@ -75,7 +76,8 @@ __export(src_exports, {
75
76
  sanitizeErrorMessage: () => sanitizeErrorMessage,
76
77
  sanitizeFilename: () => sanitizeFilename,
77
78
  shouldRunSeeding: () => shouldRunSeeding,
78
- startPublishScheduler: () => startPublishScheduler
79
+ startPublishScheduler: () => startPublishScheduler,
80
+ validateMimeType: () => validateMimeType2
79
81
  });
80
82
  module.exports = __toCommonJS(src_exports);
81
83
 
@@ -344,12 +346,26 @@ var ReferentialIntegrityError = class extends Error {
344
346
  this.constraint = constraint;
345
347
  }
346
348
  };
349
+ function isNamedTab(tab) {
350
+ return typeof tab.name === "string" && tab.name.length > 0;
351
+ }
347
352
  function flattenDataFields(fields) {
348
353
  const result = [];
349
354
  for (const field of fields) {
350
355
  if (field.type === "tabs") {
351
356
  for (const tab of field.tabs) {
352
- result.push(...flattenDataFields(tab.fields));
357
+ if (isNamedTab(tab)) {
358
+ const syntheticGroup = {
359
+ name: tab.name,
360
+ type: "group",
361
+ label: tab.label,
362
+ description: tab.description,
363
+ fields: tab.fields
364
+ };
365
+ result.push(syntheticGroup);
366
+ } else {
367
+ result.push(...flattenDataFields(tab.fields));
368
+ }
353
369
  }
354
370
  } else if (field.type === "collapsible" || field.type === "row") {
355
371
  result.push(...flattenDataFields(field.fields));
@@ -501,6 +517,9 @@ var MediaCollection = defineCollection({
501
517
  singular: "Media",
502
518
  plural: "Media"
503
519
  },
520
+ upload: {
521
+ mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
522
+ },
504
523
  admin: {
505
524
  useAsTitle: "filename",
506
525
  defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
@@ -521,7 +540,6 @@ var MediaCollection = defineCollection({
521
540
  description: "File size in bytes"
522
541
  }),
523
542
  text("path", {
524
- required: true,
525
543
  label: "Storage Path",
526
544
  description: "Path/key where the file is stored",
527
545
  admin: {
@@ -802,7 +820,20 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
802
820
  for (const field of fields) {
803
821
  if (field.type === "tabs") {
804
822
  for (const tab of field.tabs) {
805
- processedData = await runFieldHooks(hookType, tab.fields, processedData, req, operation);
823
+ if (isNamedTab(tab)) {
824
+ const nested = processedData[tab.name];
825
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
826
+ processedData[tab.name] = await runFieldHooks(
827
+ hookType,
828
+ tab.fields,
829
+ nested,
830
+ req,
831
+ operation
832
+ );
833
+ }
834
+ } else {
835
+ processedData = await runFieldHooks(hookType, tab.fields, processedData, req, operation);
836
+ }
806
837
  }
807
838
  continue;
808
839
  }
@@ -812,6 +843,7 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
812
843
  }
813
844
  const hooks = field.hooks?.[hookType];
814
845
  if (hooks && hooks.length > 0) {
846
+ const fieldExistsInData = field.name in processedData;
815
847
  let value = processedData[field.name];
816
848
  for (const hook of hooks) {
817
849
  const result = await Promise.resolve(
@@ -826,7 +858,9 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
826
858
  value = result;
827
859
  }
828
860
  }
829
- processedData[field.name] = value;
861
+ if (fieldExistsInData || value !== void 0) {
862
+ processedData[field.name] = value;
863
+ }
830
864
  }
831
865
  if (field.type === "group" && processedData[field.name] && typeof processedData[field.name] === "object" && !Array.isArray(processedData[field.name])) {
832
866
  processedData[field.name] = await runFieldHooks(
@@ -4110,6 +4144,64 @@ async function handleUpload(config, request) {
4110
4144
  };
4111
4145
  }
4112
4146
  }
4147
+ async function handleCollectionUpload(globalConfig, request) {
4148
+ const { adapter } = globalConfig;
4149
+ const { file, user, fields, collectionSlug, collectionUpload } = request;
4150
+ const maxFileSize = collectionUpload.maxFileSize ?? globalConfig.maxFileSize ?? 10 * 1024 * 1024;
4151
+ const allowedMimeTypes = collectionUpload.mimeTypes ?? globalConfig.allowedMimeTypes ?? [];
4152
+ try {
4153
+ if (!user) {
4154
+ return {
4155
+ status: 401,
4156
+ error: "Authentication required to upload files"
4157
+ };
4158
+ }
4159
+ const sizeError = validateFileSize(file, maxFileSize);
4160
+ if (sizeError) {
4161
+ return { status: 400, error: sizeError };
4162
+ }
4163
+ const mimeError = validateMimeType2(file.mimeType, allowedMimeTypes);
4164
+ if (mimeError) {
4165
+ return { status: 400, error: mimeError };
4166
+ }
4167
+ if (file.buffer && file.buffer.length > 0) {
4168
+ const magicByteResult = validateMimeType(
4169
+ file.buffer,
4170
+ file.mimeType,
4171
+ allowedMimeTypes
4172
+ );
4173
+ if (!magicByteResult.valid) {
4174
+ return {
4175
+ status: 400,
4176
+ error: magicByteResult.error ?? "File content does not match claimed type"
4177
+ };
4178
+ }
4179
+ }
4180
+ const storedFile = await adapter.upload(file);
4181
+ const docData = {
4182
+ ...fields,
4183
+ filename: file.originalName,
4184
+ mimeType: file.mimeType,
4185
+ filesize: file.size,
4186
+ path: storedFile.path,
4187
+ url: storedFile.url
4188
+ };
4189
+ const api = getMomentumAPI().setContext({ user });
4190
+ const doc = await api.collection(collectionSlug).create(docData);
4191
+ return {
4192
+ status: 201,
4193
+ doc
4194
+ };
4195
+ } catch (error) {
4196
+ if (error instanceof Error) {
4197
+ if (error.message.includes("Access denied")) {
4198
+ return { status: 403, error: error.message };
4199
+ }
4200
+ return { status: 500, error: `Upload failed: ${error.message}` };
4201
+ }
4202
+ return { status: 500, error: "Upload failed: Unknown error" };
4203
+ }
4204
+ }
4113
4205
  async function handleFileDelete(adapter, path) {
4114
4206
  return adapter.delete(path);
4115
4207
  }
@@ -5229,6 +5321,7 @@ function coerceCsvValue(value, fieldType) {
5229
5321
  getMomentumAPI,
5230
5322
  getSwaggerUIHTML,
5231
5323
  getUploadConfig,
5324
+ handleCollectionUpload,
5232
5325
  handleFileDelete,
5233
5326
  handleFileGet,
5234
5327
  handleUpload,
@@ -5250,5 +5343,6 @@ function coerceCsvValue(value, fieldType) {
5250
5343
  sanitizeErrorMessage,
5251
5344
  sanitizeFilename,
5252
5345
  shouldRunSeeding,
5253
- startPublishScheduler
5346
+ startPublishScheduler,
5347
+ validateMimeType
5254
5348
  });
package/index.js CHANGED
@@ -263,12 +263,26 @@ var ReferentialIntegrityError = class extends Error {
263
263
  this.constraint = constraint;
264
264
  }
265
265
  };
266
+ function isNamedTab(tab) {
267
+ return typeof tab.name === "string" && tab.name.length > 0;
268
+ }
266
269
  function flattenDataFields(fields) {
267
270
  const result = [];
268
271
  for (const field of fields) {
269
272
  if (field.type === "tabs") {
270
273
  for (const tab of field.tabs) {
271
- result.push(...flattenDataFields(tab.fields));
274
+ if (isNamedTab(tab)) {
275
+ const syntheticGroup = {
276
+ name: tab.name,
277
+ type: "group",
278
+ label: tab.label,
279
+ description: tab.description,
280
+ fields: tab.fields
281
+ };
282
+ result.push(syntheticGroup);
283
+ } else {
284
+ result.push(...flattenDataFields(tab.fields));
285
+ }
272
286
  }
273
287
  } else if (field.type === "collapsible" || field.type === "row") {
274
288
  result.push(...flattenDataFields(field.fields));
@@ -420,6 +434,9 @@ var MediaCollection = defineCollection({
420
434
  singular: "Media",
421
435
  plural: "Media"
422
436
  },
437
+ upload: {
438
+ mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
439
+ },
423
440
  admin: {
424
441
  useAsTitle: "filename",
425
442
  defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
@@ -440,7 +457,6 @@ var MediaCollection = defineCollection({
440
457
  description: "File size in bytes"
441
458
  }),
442
459
  text("path", {
443
- required: true,
444
460
  label: "Storage Path",
445
461
  description: "Path/key where the file is stored",
446
462
  admin: {
@@ -721,7 +737,20 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
721
737
  for (const field of fields) {
722
738
  if (field.type === "tabs") {
723
739
  for (const tab of field.tabs) {
724
- processedData = await runFieldHooks(hookType, tab.fields, processedData, req, operation);
740
+ if (isNamedTab(tab)) {
741
+ const nested = processedData[tab.name];
742
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
743
+ processedData[tab.name] = await runFieldHooks(
744
+ hookType,
745
+ tab.fields,
746
+ nested,
747
+ req,
748
+ operation
749
+ );
750
+ }
751
+ } else {
752
+ processedData = await runFieldHooks(hookType, tab.fields, processedData, req, operation);
753
+ }
725
754
  }
726
755
  continue;
727
756
  }
@@ -731,6 +760,7 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
731
760
  }
732
761
  const hooks = field.hooks?.[hookType];
733
762
  if (hooks && hooks.length > 0) {
763
+ const fieldExistsInData = field.name in processedData;
734
764
  let value = processedData[field.name];
735
765
  for (const hook of hooks) {
736
766
  const result = await Promise.resolve(
@@ -745,7 +775,9 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
745
775
  value = result;
746
776
  }
747
777
  }
748
- processedData[field.name] = value;
778
+ if (fieldExistsInData || value !== void 0) {
779
+ processedData[field.name] = value;
780
+ }
749
781
  }
750
782
  if (field.type === "group" && processedData[field.name] && typeof processedData[field.name] === "object" && !Array.isArray(processedData[field.name])) {
751
783
  processedData[field.name] = await runFieldHooks(
@@ -4042,6 +4074,64 @@ async function handleUpload(config, request) {
4042
4074
  };
4043
4075
  }
4044
4076
  }
4077
+ async function handleCollectionUpload(globalConfig, request) {
4078
+ const { adapter } = globalConfig;
4079
+ const { file, user, fields, collectionSlug, collectionUpload } = request;
4080
+ const maxFileSize = collectionUpload.maxFileSize ?? globalConfig.maxFileSize ?? 10 * 1024 * 1024;
4081
+ const allowedMimeTypes = collectionUpload.mimeTypes ?? globalConfig.allowedMimeTypes ?? [];
4082
+ try {
4083
+ if (!user) {
4084
+ return {
4085
+ status: 401,
4086
+ error: "Authentication required to upload files"
4087
+ };
4088
+ }
4089
+ const sizeError = validateFileSize(file, maxFileSize);
4090
+ if (sizeError) {
4091
+ return { status: 400, error: sizeError };
4092
+ }
4093
+ const mimeError = validateMimeType2(file.mimeType, allowedMimeTypes);
4094
+ if (mimeError) {
4095
+ return { status: 400, error: mimeError };
4096
+ }
4097
+ if (file.buffer && file.buffer.length > 0) {
4098
+ const magicByteResult = validateMimeType(
4099
+ file.buffer,
4100
+ file.mimeType,
4101
+ allowedMimeTypes
4102
+ );
4103
+ if (!magicByteResult.valid) {
4104
+ return {
4105
+ status: 400,
4106
+ error: magicByteResult.error ?? "File content does not match claimed type"
4107
+ };
4108
+ }
4109
+ }
4110
+ const storedFile = await adapter.upload(file);
4111
+ const docData = {
4112
+ ...fields,
4113
+ filename: file.originalName,
4114
+ mimeType: file.mimeType,
4115
+ filesize: file.size,
4116
+ path: storedFile.path,
4117
+ url: storedFile.url
4118
+ };
4119
+ const api = getMomentumAPI().setContext({ user });
4120
+ const doc = await api.collection(collectionSlug).create(docData);
4121
+ return {
4122
+ status: 201,
4123
+ doc
4124
+ };
4125
+ } catch (error) {
4126
+ if (error instanceof Error) {
4127
+ if (error.message.includes("Access denied")) {
4128
+ return { status: 403, error: error.message };
4129
+ }
4130
+ return { status: 500, error: `Upload failed: ${error.message}` };
4131
+ }
4132
+ return { status: 500, error: "Upload failed: Unknown error" };
4133
+ }
4134
+ }
4045
4135
  async function handleFileDelete(adapter, path) {
4046
4136
  return adapter.delete(path);
4047
4137
  }
@@ -5160,6 +5250,7 @@ export {
5160
5250
  getMomentumAPI,
5161
5251
  getSwaggerUIHTML,
5162
5252
  getUploadConfig,
5253
+ handleCollectionUpload,
5163
5254
  handleFileDelete,
5164
5255
  handleFileGet,
5165
5256
  handleUpload,
@@ -5181,5 +5272,6 @@ export {
5181
5272
  sanitizeErrorMessage,
5182
5273
  sanitizeFilename,
5183
5274
  shouldRunSeeding,
5184
- startPublishScheduler
5275
+ startPublishScheduler,
5276
+ validateMimeType2 as validateMimeType
5185
5277
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momentumcms/server-core",
3
- "version": "0.1.10",
3
+ "version": "0.3.0",
4
4
  "description": "Framework-agnostic server handlers for Momentum CMS",
5
5
  "license": "MIT",
6
6
  "author": "Momentum CMS Contributors",
package/src/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { buildGraphQLSchema, type GraphQLContext } from './lib/graphql-schema';
12
12
  export { executeGraphQL, type GraphQLRequestBody, type GraphQLResult } from './lib/graphql-handler';
13
13
  export { GraphQLJSON } from './lib/graphql-scalars';
14
14
  export { generateApiKey, hashApiKey, getKeyPrefix, isValidApiKeyFormat, generateApiKeyId, createAdapterApiKeyStore, createPostgresApiKeyStore, API_KEYS_TABLE_SQL_POSTGRES, API_KEYS_TABLE_SQL_SQLITE, type ApiKeyRecord, type CreateApiKeyResult, type CreateApiKeyOptions, type ApiKeyStore, } from './lib/api-keys';
15
- export { handleUpload, handleFileDelete, handleFileGet, getUploadConfig, type UploadRequest, type UploadResponse, type UploadConfig, } from './lib/upload-handler';
15
+ export { handleUpload, handleCollectionUpload, handleFileDelete, handleFileGet, getUploadConfig, validateMimeType, type UploadRequest, type UploadResponse, type UploadConfig, type CollectionUploadRequest, type CollectionUploadResponse, } from './lib/upload-handler';
16
16
  export { generateOpenAPISpec, type OpenAPIDocument, type OpenAPIGeneratorOptions, } from './lib/openapi-generator';
17
17
  export { getSwaggerUIHTML } from './lib/swagger-ui-html';
18
18
  export { renderPreviewHTML, type PreviewRenderOptions } from './lib/preview-renderer';
@@ -2,7 +2,7 @@
2
2
  * Upload Handler for Momentum CMS
3
3
  * Framework-agnostic file upload handling
4
4
  */
5
- import type { StorageAdapter, UploadedFile, MomentumConfig, MediaDocument } from '@momentumcms/core';
5
+ import type { StorageAdapter, UploadedFile, MomentumConfig, MediaDocument, UploadCollectionConfig } from '@momentumcms/core';
6
6
  import { type MomentumAPIContext } from './momentum-api';
7
7
  /**
8
8
  * Upload request from the client.
@@ -43,6 +43,11 @@ export interface UploadConfig {
43
43
  * Get upload configuration from MomentumConfig.
44
44
  */
45
45
  export declare function getUploadConfig(config: MomentumConfig): UploadConfig | null;
46
+ /**
47
+ * Validate claimed MIME type against an allow-list.
48
+ * Returns an error message if the type is not allowed, or null if OK.
49
+ */
50
+ export declare function validateMimeType(mimeType: string, allowedTypes: string[]): string | null;
46
51
  /**
47
52
  * Handle file upload.
48
53
  *
@@ -51,6 +56,45 @@ export declare function getUploadConfig(config: MomentumConfig): UploadConfig |
51
56
  * @returns Upload response with created media document or error
52
57
  */
53
58
  export declare function handleUpload(config: UploadConfig, request: UploadRequest): Promise<UploadResponse>;
59
+ /**
60
+ * Upload request for a collection-level upload.
61
+ * Used when POST /api/{slug} with multipart/form-data hits an upload collection.
62
+ */
63
+ export interface CollectionUploadRequest {
64
+ /** The uploaded file */
65
+ file: UploadedFile;
66
+ /** User context for access control */
67
+ user?: MomentumAPIContext['user'];
68
+ /** Non-file form fields from multipart body (e.g., alt, title) */
69
+ fields: Record<string, unknown>;
70
+ /** Target collection slug */
71
+ collectionSlug: string;
72
+ /** Collection-level upload config */
73
+ collectionUpload: UploadCollectionConfig;
74
+ }
75
+ /**
76
+ * Response from a collection-level upload.
77
+ */
78
+ export interface CollectionUploadResponse {
79
+ /** Created document (with auto-populated file metadata) */
80
+ doc?: Record<string, unknown>;
81
+ /** Error message if upload failed */
82
+ error?: string;
83
+ /** HTTP status code */
84
+ status: number;
85
+ }
86
+ /**
87
+ * Handle file upload for an upload collection.
88
+ * Stores the file, auto-populates metadata fields, merges with user-provided fields,
89
+ * and creates the document in the target collection.
90
+ *
91
+ * Collection-level config overrides global config for mimeTypes and maxFileSize.
92
+ *
93
+ * @param globalConfig - Global upload configuration (storage adapter, defaults)
94
+ * @param request - Collection upload request with file, user fields, and collection config
95
+ * @returns Response with created document or error
96
+ */
97
+ export declare function handleCollectionUpload(globalConfig: UploadConfig, request: CollectionUploadRequest): Promise<CollectionUploadResponse>;
54
98
  /**
55
99
  * Handle file deletion.
56
100
  *