@momentumcms/server-core 0.2.0 → 0.4.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,36 @@
1
+ ## 0.4.0 (2026-02-22)
2
+
3
+ ### 🚀 Features
4
+
5
+ - SEO plugin recovery, E2E fixes, and CLI templates ([#37](https://github.com/DonaldMurillo/momentum-cms/pull/37), [#33](https://github.com/DonaldMurillo/momentum-cms/issues/33))
6
+ - blocks showcase with articles, pages, and UI fixes ([#36](https://github.com/DonaldMurillo/momentum-cms/pull/36))
7
+
8
+ ### 🩹 Fixes
9
+
10
+ - resolve lint errors, fix vitest config excludes, and fix CLI template test assertion ([5124f72](https://github.com/DonaldMurillo/momentum-cms/commit/5124f72))
11
+
12
+ ### ❤️ Thank You
13
+
14
+ - Claude Opus 4.6
15
+ - Donald Murillo @DonaldMurillo
16
+
17
+ ## 0.3.0 (2026-02-20)
18
+
19
+ ### 🚀 Features
20
+
21
+ - add article slugs, detail pages, live preview, and fix PATCH field hooks ([454b61c](https://github.com/DonaldMurillo/momentum-cms/commit/454b61c))
22
+ - add named tabs support with nested data grouping and UI improvements ([#30](https://github.com/DonaldMurillo/momentum-cms/pull/30))
23
+
24
+ ### 🩹 Fixes
25
+
26
+ - address code review issues across admin, server-core, and e2e ([4664463](https://github.com/DonaldMurillo/momentum-cms/commit/4664463))
27
+ - 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))
28
+
29
+ ### ❤️ Thank You
30
+
31
+ - Claude Opus 4.6
32
+ - Donald Murillo @DonaldMurillo
33
+
1
34
  ## 0.2.0 (2026-02-17)
2
35
 
3
36
  ### 🚀 Features
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
 
@@ -515,6 +517,9 @@ var MediaCollection = defineCollection({
515
517
  singular: "Media",
516
518
  plural: "Media"
517
519
  },
520
+ upload: {
521
+ mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
522
+ },
518
523
  admin: {
519
524
  useAsTitle: "filename",
520
525
  defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
@@ -535,7 +540,6 @@ var MediaCollection = defineCollection({
535
540
  description: "File size in bytes"
536
541
  }),
537
542
  text("path", {
538
- required: true,
539
543
  label: "Storage Path",
540
544
  description: "Path/key where the file is stored",
541
545
  admin: {
@@ -839,6 +843,7 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
839
843
  }
840
844
  const hooks = field.hooks?.[hookType];
841
845
  if (hooks && hooks.length > 0) {
846
+ const fieldExistsInData = field.name in processedData;
842
847
  let value = processedData[field.name];
843
848
  for (const hook of hooks) {
844
849
  const result = await Promise.resolve(
@@ -853,7 +858,9 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
853
858
  value = result;
854
859
  }
855
860
  }
856
- processedData[field.name] = value;
861
+ if (fieldExistsInData || value !== void 0) {
862
+ processedData[field.name] = value;
863
+ }
857
864
  }
858
865
  if (field.type === "group" && processedData[field.name] && typeof processedData[field.name] === "object" && !Array.isArray(processedData[field.name])) {
859
866
  processedData[field.name] = await runFieldHooks(
@@ -4137,6 +4144,64 @@ async function handleUpload(config, request) {
4137
4144
  };
4138
4145
  }
4139
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
+ }
4140
4205
  async function handleFileDelete(adapter, path) {
4141
4206
  return adapter.delete(path);
4142
4207
  }
@@ -5256,6 +5321,7 @@ function coerceCsvValue(value, fieldType) {
5256
5321
  getMomentumAPI,
5257
5322
  getSwaggerUIHTML,
5258
5323
  getUploadConfig,
5324
+ handleCollectionUpload,
5259
5325
  handleFileDelete,
5260
5326
  handleFileGet,
5261
5327
  handleUpload,
@@ -5277,5 +5343,6 @@ function coerceCsvValue(value, fieldType) {
5277
5343
  sanitizeErrorMessage,
5278
5344
  sanitizeFilename,
5279
5345
  shouldRunSeeding,
5280
- startPublishScheduler
5346
+ startPublishScheduler,
5347
+ validateMimeType
5281
5348
  });
package/index.js CHANGED
@@ -434,6 +434,9 @@ var MediaCollection = defineCollection({
434
434
  singular: "Media",
435
435
  plural: "Media"
436
436
  },
437
+ upload: {
438
+ mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
439
+ },
437
440
  admin: {
438
441
  useAsTitle: "filename",
439
442
  defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
@@ -454,7 +457,6 @@ var MediaCollection = defineCollection({
454
457
  description: "File size in bytes"
455
458
  }),
456
459
  text("path", {
457
- required: true,
458
460
  label: "Storage Path",
459
461
  description: "Path/key where the file is stored",
460
462
  admin: {
@@ -758,6 +760,7 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
758
760
  }
759
761
  const hooks = field.hooks?.[hookType];
760
762
  if (hooks && hooks.length > 0) {
763
+ const fieldExistsInData = field.name in processedData;
761
764
  let value = processedData[field.name];
762
765
  for (const hook of hooks) {
763
766
  const result = await Promise.resolve(
@@ -772,7 +775,9 @@ async function runFieldHooks(hookType, fields, data, req, operation) {
772
775
  value = result;
773
776
  }
774
777
  }
775
- processedData[field.name] = value;
778
+ if (fieldExistsInData || value !== void 0) {
779
+ processedData[field.name] = value;
780
+ }
776
781
  }
777
782
  if (field.type === "group" && processedData[field.name] && typeof processedData[field.name] === "object" && !Array.isArray(processedData[field.name])) {
778
783
  processedData[field.name] = await runFieldHooks(
@@ -4069,6 +4074,64 @@ async function handleUpload(config, request) {
4069
4074
  };
4070
4075
  }
4071
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
+ }
4072
4135
  async function handleFileDelete(adapter, path) {
4073
4136
  return adapter.delete(path);
4074
4137
  }
@@ -5187,6 +5250,7 @@ export {
5187
5250
  getMomentumAPI,
5188
5251
  getSwaggerUIHTML,
5189
5252
  getUploadConfig,
5253
+ handleCollectionUpload,
5190
5254
  handleFileDelete,
5191
5255
  handleFileGet,
5192
5256
  handleUpload,
@@ -5208,5 +5272,6 @@ export {
5208
5272
  sanitizeErrorMessage,
5209
5273
  sanitizeFilename,
5210
5274
  shouldRunSeeding,
5211
- startPublishScheduler
5275
+ startPublishScheduler,
5276
+ validateMimeType2 as validateMimeType
5212
5277
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momentumcms/server-core",
3
- "version": "0.2.0",
3
+ "version": "0.4.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
  *