@jgardner04/ghost-mcp-server 1.13.4 → 1.14.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.
Files changed (40) hide show
  1. package/README.md +68 -0
  2. package/package.json +7 -3
  3. package/src/__tests__/helpers/testUtils.js +15 -1
  4. package/src/__tests__/mcp_server.test.js +152 -1
  5. package/src/__tests__/mcp_server_pages.test.js +23 -6
  6. package/src/controllers/__tests__/imageController.test.js +2 -2
  7. package/src/controllers/imageController.js +11 -10
  8. package/src/mcp_server.js +647 -1203
  9. package/src/routes/__tests__/imageRoutes.test.js +2 -2
  10. package/src/schemas/__tests__/common.test.js +3 -3
  11. package/src/schemas/__tests__/pageSchemas.test.js +11 -2
  12. package/src/schemas/common.js +3 -2
  13. package/src/schemas/pageSchemas.js +1 -1
  14. package/src/schemas/postSchemas.js +1 -1
  15. package/src/services/__tests__/createResourceService.test.js +468 -0
  16. package/src/services/__tests__/ghostService.test.js +0 -19
  17. package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
  18. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
  19. package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
  20. package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
  21. package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
  22. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
  23. package/src/services/__tests__/imageProcessingService.test.js +148 -177
  24. package/src/services/__tests__/images.test.js +78 -0
  25. package/src/services/createResourceService.js +138 -0
  26. package/src/services/ghostApiClient.js +240 -0
  27. package/src/services/ghostService.js +1 -19
  28. package/src/services/ghostServiceImproved.js +76 -915
  29. package/src/services/imageProcessingService.js +100 -56
  30. package/src/services/images.js +54 -0
  31. package/src/services/members.js +127 -0
  32. package/src/services/newsletters.js +63 -0
  33. package/src/services/pageService.js +2 -2
  34. package/src/services/pages.js +116 -0
  35. package/src/services/posts.js +116 -0
  36. package/src/services/tags.js +118 -0
  37. package/src/services/tiers.js +72 -0
  38. package/src/services/validators.js +218 -0
  39. package/src/utils/__tests__/imageInputResolver.test.js +134 -0
  40. package/src/utils/imageInputResolver.js +127 -0
@@ -0,0 +1,116 @@
1
+ import { ValidationError } from '../errors/index.js';
2
+ import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
3
+ import { handleApiRequest, readResource } from './ghostApiClient.js';
4
+ import { createResourceService } from './createResourceService.js';
5
+ import { validators } from './validators.js';
6
+
7
+ const service = createResourceService({
8
+ resource: 'posts',
9
+ label: 'Post',
10
+ createDefaults: { status: 'draft' },
11
+ createOptions: { source: 'html' },
12
+ listDefaults: { limit: 15, include: 'tags,authors' },
13
+ validateCreate: (data) => validators.validatePostData(data),
14
+ validateUpdate: async (id, updateData) => {
15
+ if (updateData.status || updateData.published_at) {
16
+ let validationData = updateData;
17
+ if (!updateData.status && updateData.published_at) {
18
+ const existing = await readResource('posts', id, 'Post');
19
+ validationData = { ...updateData, status: existing.status };
20
+ }
21
+ validators.validateScheduledStatus(validationData, 'Post');
22
+ }
23
+ },
24
+ });
25
+
26
+ /**
27
+ * Creates a new post in Ghost CMS.
28
+ * @param {Object} postData - The post data
29
+ * @param {string} postData.title - Post title (required)
30
+ * @param {string} [postData.html] - HTML content (required if mobiledoc not provided)
31
+ * @param {string} [postData.mobiledoc] - Mobiledoc content (required if html not provided)
32
+ * @param {string} [postData.status='draft'] - Post status ('draft', 'published', 'scheduled')
33
+ * @param {string} [postData.published_at] - ISO 8601 date for scheduled posts
34
+ * @param {Object} [options={ source: 'html' }] - API request options
35
+ * @returns {Promise<Object>} The created post object
36
+ * @throws {ValidationError} If validation fails or Ghost returns a 422
37
+ * @throws {GhostAPIError} If the API request fails
38
+ */
39
+ export const createPost = service.create;
40
+
41
+ /**
42
+ * Updates an existing post with optimistic concurrency control.
43
+ * Validates scheduling fields when status or published_at is being changed.
44
+ * @param {string} postId - The post ID to update
45
+ * @param {Object} updateData - Fields to update on the post
46
+ * @param {Object} [options={}] - API request options
47
+ * @returns {Promise<Object>} The updated post object
48
+ * @throws {ValidationError} If the post ID is missing or scheduling validation fails
49
+ * @throws {NotFoundError} If the post is not found
50
+ * @throws {GhostAPIError} If the API request fails
51
+ */
52
+ export const updatePost = service.update;
53
+
54
+ /**
55
+ * Deletes a post by ID.
56
+ * @param {string} postId - The post ID to delete
57
+ * @returns {Promise<Object>} Deletion confirmation
58
+ * @throws {ValidationError} If the post ID is missing
59
+ * @throws {NotFoundError} If the post is not found
60
+ * @throws {GhostAPIError} If the API request fails
61
+ */
62
+ export const deletePost = service.remove;
63
+
64
+ /**
65
+ * Retrieves a single post by ID.
66
+ * @param {string} postId - The post ID to retrieve
67
+ * @param {Object} [options={}] - API request options (e.g., includes)
68
+ * @returns {Promise<Object>} The post object
69
+ * @throws {ValidationError} If the post ID is missing
70
+ * @throws {NotFoundError} If the post is not found
71
+ * @throws {GhostAPIError} If the API request fails
72
+ */
73
+ export const getPost = service.getOne;
74
+
75
+ /**
76
+ * Lists posts with optional filtering and pagination.
77
+ * @param {Object} [options={}] - Query options
78
+ * @param {number} [options.limit=15] - Number of posts to return
79
+ * @param {string} [options.include='tags,authors'] - Related resources to include
80
+ * @param {string} [options.filter] - NQL filter string
81
+ * @param {string} [options.order] - Order string (e.g., 'published_at desc')
82
+ * @returns {Promise<Array>} Array of post objects
83
+ * @throws {GhostAPIError} If the API request fails
84
+ */
85
+ export const getPosts = service.getList;
86
+
87
+ /**
88
+ * Searches posts by title using Ghost NQL fuzzy matching.
89
+ * @param {string} query - Search query string (required, non-empty)
90
+ * @param {Object} [options={}] - Additional search options
91
+ * @param {string} [options.status] - Filter by status ('draft', 'published', 'scheduled', or 'all')
92
+ * @param {number} [options.limit=15] - Maximum number of results
93
+ * @returns {Promise<Array>} Array of matching post objects
94
+ * @throws {ValidationError} If the query is empty
95
+ * @throws {GhostAPIError} If the API request fails
96
+ */
97
+ export async function searchPosts(query, options = {}) {
98
+ if (!query || query.trim().length === 0) {
99
+ throw new ValidationError('Search query is required');
100
+ }
101
+
102
+ const sanitizedQuery = sanitizeNqlValue(query);
103
+
104
+ const filterParts = [`title:~'${sanitizedQuery}'`];
105
+ if (options.status && options.status !== 'all') {
106
+ filterParts.push(`status:${options.status}`);
107
+ }
108
+
109
+ const searchOptions = {
110
+ limit: options.limit || 15,
111
+ include: 'tags,authors',
112
+ filter: filterParts.join('+'),
113
+ };
114
+
115
+ return handleApiRequest('posts', 'browse', {}, searchOptions);
116
+ }
@@ -0,0 +1,118 @@
1
+ import { GhostAPIError, ValidationError, NotFoundError } from '../errors/index.js';
2
+ import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
3
+ import { handleApiRequest, readResource } from './ghostApiClient.js';
4
+ import { createResourceService } from './createResourceService.js';
5
+ import { validators } from './validators.js';
6
+
7
+ const service = createResourceService({
8
+ resource: 'tags',
9
+ label: 'Tag',
10
+ listDefaults: { limit: 15 },
11
+ });
12
+
13
+ /**
14
+ * Creates a new tag in Ghost CMS. Auto-generates a slug from the name if not provided.
15
+ * If a tag with the same name already exists, returns the existing tag instead of failing.
16
+ * @param {Object} tagData - The tag data
17
+ * @param {string} tagData.name - Tag name (required)
18
+ * @param {string} [tagData.slug] - Tag slug (auto-generated from name if omitted)
19
+ * @param {string} [tagData.description] - Tag description
20
+ * @returns {Promise<Object>} The created (or existing) tag object
21
+ * @throws {ValidationError} If validation fails or Ghost returns a 422 (non-duplicate)
22
+ * @throws {GhostAPIError} If the API request fails
23
+ */
24
+ export async function createTag(tagData) {
25
+ validators.validateTagData(tagData);
26
+
27
+ const dataToCreate = tagData.slug
28
+ ? { ...tagData }
29
+ : {
30
+ ...tagData,
31
+ slug: tagData.name
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, '-')
34
+ .replace(/^-+|-+$/g, ''),
35
+ };
36
+
37
+ try {
38
+ return await handleApiRequest('tags', 'add', dataToCreate);
39
+ } catch (error) {
40
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
41
+ if (error.originalError.includes('already exists')) {
42
+ const existingTags = await getTags({ filter: `name:'${sanitizeNqlValue(tagData.name)}'` });
43
+ if (existingTags.length > 0) {
44
+ return existingTags[0];
45
+ }
46
+ }
47
+ throw new ValidationError('Tag creation failed', [
48
+ { field: 'tag', message: error.originalError },
49
+ ]);
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Lists tags with optional filtering and pagination.
57
+ * @param {Object} [options={}] - Query options
58
+ * @param {number} [options.limit=15] - Number of tags to return
59
+ * @param {string} [options.filter] - NQL filter string
60
+ * @param {string} [options.order] - Order string
61
+ * @returns {Promise<Array>} Array of tag objects (empty array if none found)
62
+ * @throws {GhostAPIError} If the API request fails
63
+ */
64
+ export const getTags = service.getList;
65
+
66
+ /**
67
+ * Retrieves a single tag by ID.
68
+ * @param {string} tagId - The tag ID to retrieve
69
+ * @param {Object} [options={}] - API request options
70
+ * @returns {Promise<Object>} The tag object
71
+ * @throws {ValidationError} If the tag ID is missing
72
+ * @throws {NotFoundError} If the tag is not found
73
+ * @throws {GhostAPIError} If the API request fails
74
+ */
75
+ export const getTag = service.getOne;
76
+
77
+ /**
78
+ * Updates an existing tag. Validates update data and checks that the tag exists first.
79
+ * Tags use direct edit (not OCC) since they don't have concurrent editing concerns.
80
+ * @param {string} tagId - The tag ID to update
81
+ * @param {Object} updateData - Fields to update on the tag
82
+ * @param {string} [updateData.name] - Updated tag name
83
+ * @param {string} [updateData.slug] - Updated tag slug
84
+ * @param {string} [updateData.description] - Updated tag description
85
+ * @returns {Promise<Object>} The updated tag object
86
+ * @throws {ValidationError} If the tag ID is missing or update data is invalid
87
+ * @throws {NotFoundError} If the tag is not found
88
+ * @throws {GhostAPIError} If the API request fails
89
+ */
90
+ export async function updateTag(tagId, updateData) {
91
+ validators.requireId(tagId, 'Tag');
92
+ validators.validateTagUpdateData(updateData);
93
+
94
+ try {
95
+ await readResource('tags', tagId, 'Tag');
96
+ return await handleApiRequest('tags', 'edit', { id: tagId, ...updateData });
97
+ } catch (error) {
98
+ if (error instanceof NotFoundError) {
99
+ throw error;
100
+ }
101
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
102
+ throw new ValidationError('Tag update failed', [
103
+ { field: 'tag', message: error.originalError },
104
+ ]);
105
+ }
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Deletes a tag by ID.
112
+ * @param {string} tagId - The tag ID to delete
113
+ * @returns {Promise<Object>} Deletion confirmation
114
+ * @throws {ValidationError} If the tag ID is missing
115
+ * @throws {NotFoundError} If the tag is not found
116
+ * @throws {GhostAPIError} If the API request fails
117
+ */
118
+ export const deleteTag = service.remove;
@@ -0,0 +1,72 @@
1
+ import { createResourceService } from './createResourceService.js';
2
+ import {
3
+ validateTierData,
4
+ validateTierUpdateData,
5
+ validateTierQueryOptions,
6
+ } from './tierService.js';
7
+
8
+ const service = createResourceService({
9
+ resource: 'tiers',
10
+ label: 'Tier',
11
+ listDefaults: { limit: 15 },
12
+ validateCreate: (data) => validateTierData(data),
13
+ validateUpdate: (_id, data) => validateTierUpdateData(data),
14
+ });
15
+
16
+ /**
17
+ * Create a new tier (membership level)
18
+ * @param {Object} tierData - Tier data
19
+ * @param {Object} [options={}] - Options for the API request
20
+ * @returns {Promise<Object>} Created tier
21
+ * @throws {ValidationError} If validation fails or Ghost returns a 422
22
+ * @throws {GhostAPIError} If the API request fails
23
+ */
24
+ export const createTier = service.create;
25
+
26
+ /**
27
+ * Update an existing tier with optimistic concurrency control.
28
+ * @param {string} id - Tier ID
29
+ * @param {Object} updateData - Tier update data
30
+ * @param {Object} [options={}] - Options for the API request
31
+ * @returns {Promise<Object>} Updated tier
32
+ * @throws {ValidationError} If the tier ID is missing or update data is invalid
33
+ * @throws {NotFoundError} If the tier is not found
34
+ * @throws {GhostAPIError} If the API request fails
35
+ */
36
+ export const updateTier = service.update;
37
+
38
+ /**
39
+ * Delete a tier by ID.
40
+ * @param {string} id - Tier ID
41
+ * @returns {Promise<Object>} Deletion result
42
+ * @throws {ValidationError} If the tier ID is missing
43
+ * @throws {NotFoundError} If the tier is not found
44
+ * @throws {GhostAPIError} If the API request fails
45
+ */
46
+ export const deleteTier = service.remove;
47
+
48
+ /**
49
+ * Get all tiers with optional filtering
50
+ * @param {Object} [options={}] - Query options
51
+ * @param {number} [options.limit] - Number of tiers to return (1-100, default 15)
52
+ * @param {number} [options.page] - Page number
53
+ * @param {string} [options.filter] - NQL filter string (e.g., "type:paid", "type:free")
54
+ * @param {string} [options.order] - Order string
55
+ * @param {string} [options.include] - Include string
56
+ * @returns {Promise<Array>} Array of tiers
57
+ * @throws {GhostAPIError} If the API request fails
58
+ */
59
+ export async function getTiers(options = {}) {
60
+ validateTierQueryOptions(options);
61
+ return service.getList(options);
62
+ }
63
+
64
+ /**
65
+ * Get a single tier by ID.
66
+ * @param {string} id - Tier ID
67
+ * @returns {Promise<Object>} Tier object
68
+ * @throws {ValidationError} If the tier ID is missing
69
+ * @throws {NotFoundError} If the tier is not found
70
+ * @throws {GhostAPIError} If the API request fails
71
+ */
72
+ export const getTier = service.getOne;
@@ -0,0 +1,218 @@
1
+ import { promises as fs } from 'fs';
2
+ import { ValidationError, NotFoundError } from '../errors/index.js';
3
+
4
+ /**
5
+ * Input validation helpers
6
+ */
7
+ export const validators = {
8
+ /**
9
+ * Validates that an ID is a non-empty string. Used by CRUD helpers to enforce
10
+ * consistent ID validation across all resource types.
11
+ * @param {string} id - The resource ID to validate
12
+ * @param {string} entityName - Human-readable resource name for error messages (e.g., 'Post', 'Tag')
13
+ * @throws {ValidationError} If the ID is falsy, not a string, or empty/whitespace
14
+ */
15
+ requireId(id, entityName) {
16
+ if (!id || typeof id !== 'string' || id.trim().length === 0) {
17
+ throw new ValidationError(`${entityName} ID is required`);
18
+ }
19
+ },
20
+
21
+ /**
22
+ * Validates scheduling fields for posts and pages. Ensures published_at is present
23
+ * when status is 'scheduled' and that the date is valid and in the future.
24
+ * @param {Object} data - The resource data containing status and/or published_at
25
+ * @param {string} [data.status] - Resource status ('draft', 'published', 'scheduled')
26
+ * @param {string} [data.published_at] - ISO 8601 date string for scheduling
27
+ * @param {string} [resourceLabel='Resource'] - Human-readable label for error messages
28
+ * @throws {ValidationError} If scheduling validation fails
29
+ */
30
+ validateScheduledStatus(data, resourceLabel = 'Resource') {
31
+ const errors = [];
32
+
33
+ if (data.status === 'scheduled' && !data.published_at) {
34
+ errors.push({
35
+ field: 'published_at',
36
+ message: 'published_at is required when status is scheduled',
37
+ });
38
+ }
39
+
40
+ if (data.published_at) {
41
+ const publishDate = new Date(data.published_at);
42
+ if (isNaN(publishDate.getTime())) {
43
+ errors.push({ field: 'published_at', message: 'Invalid date format' });
44
+ } else if (data.status === 'scheduled' && publishDate <= new Date()) {
45
+ errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
46
+ }
47
+ }
48
+
49
+ if (errors.length > 0) {
50
+ throw new ValidationError(`${resourceLabel} validation failed`, errors);
51
+ }
52
+ },
53
+
54
+ /**
55
+ * Validates post creation data. Requires a title and either html or mobiledoc content.
56
+ * Also validates status values and delegates to validateScheduledStatus for scheduling.
57
+ * @param {Object} postData - The post data to validate
58
+ * @param {string} postData.title - Post title (required, non-empty)
59
+ * @param {string} [postData.html] - HTML content (required if mobiledoc not provided)
60
+ * @param {string} [postData.mobiledoc] - Mobiledoc content (required if html not provided)
61
+ * @param {string} [postData.status] - Post status ('draft', 'published', 'scheduled')
62
+ * @param {string} [postData.published_at] - ISO 8601 date for scheduled posts
63
+ * @throws {ValidationError} If validation fails
64
+ */
65
+ validatePostData(postData) {
66
+ const errors = [];
67
+
68
+ if (!postData.title || postData.title.trim().length === 0) {
69
+ errors.push({ field: 'title', message: 'Title is required' });
70
+ }
71
+
72
+ if (!postData.html && !postData.mobiledoc) {
73
+ errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
74
+ }
75
+
76
+ if (postData.status && !['draft', 'published', 'scheduled'].includes(postData.status)) {
77
+ errors.push({
78
+ field: 'status',
79
+ message: 'Invalid status. Must be draft, published, or scheduled',
80
+ });
81
+ }
82
+
83
+ if (errors.length > 0) {
84
+ throw new ValidationError('Post validation failed', errors);
85
+ }
86
+
87
+ this.validateScheduledStatus(postData, 'Post');
88
+ },
89
+
90
+ /**
91
+ * Validates tag creation data. Requires a non-empty name and validates slug format.
92
+ * @param {Object} tagData - The tag data to validate
93
+ * @param {string} tagData.name - Tag name (required, non-empty)
94
+ * @param {string} [tagData.slug] - Tag slug (lowercase letters, numbers, hyphens only)
95
+ * @throws {ValidationError} If validation fails
96
+ */
97
+ validateTagData(tagData) {
98
+ const errors = [];
99
+
100
+ if (!tagData.name || tagData.name.trim().length === 0) {
101
+ errors.push({ field: 'name', message: 'Tag name is required' });
102
+ }
103
+
104
+ if (tagData.slug && !/^[a-z0-9-]+$/.test(tagData.slug)) {
105
+ errors.push({
106
+ field: 'slug',
107
+ message: 'Slug must contain only lowercase letters, numbers, and hyphens',
108
+ });
109
+ }
110
+
111
+ if (errors.length > 0) {
112
+ throw new ValidationError('Tag validation failed', errors);
113
+ }
114
+ },
115
+
116
+ /**
117
+ * Validates tag update data. Name is optional but cannot be empty if provided.
118
+ * Validates slug format if provided.
119
+ * @param {Object} updateData - The tag update data to validate
120
+ * @param {string} [updateData.name] - Tag name (cannot be empty if provided)
121
+ * @param {string} [updateData.slug] - Tag slug (lowercase letters, numbers, hyphens only)
122
+ * @throws {ValidationError} If validation fails
123
+ */
124
+ validateTagUpdateData(updateData) {
125
+ const errors = [];
126
+
127
+ // Name is optional in updates, but if provided, it cannot be empty
128
+ if (updateData.name !== undefined && updateData.name.trim().length === 0) {
129
+ errors.push({ field: 'name', message: 'Tag name cannot be empty' });
130
+ }
131
+
132
+ // Validate slug format if provided
133
+ if (updateData.slug && !/^[a-z0-9-]+$/.test(updateData.slug)) {
134
+ errors.push({
135
+ field: 'slug',
136
+ message: 'Slug must contain only lowercase letters, numbers, and hyphens',
137
+ });
138
+ }
139
+
140
+ if (errors.length > 0) {
141
+ throw new ValidationError('Tag update validation failed', errors);
142
+ }
143
+ },
144
+
145
+ /**
146
+ * Validates that an image path is a non-empty string and that the file exists on disk.
147
+ * @param {string} imagePath - Absolute path to the image file
148
+ * @returns {Promise<void>}
149
+ * @throws {ValidationError} If imagePath is falsy or not a string
150
+ * @throws {NotFoundError} If the file does not exist at the given path
151
+ */
152
+ async validateImagePath(imagePath) {
153
+ if (!imagePath || typeof imagePath !== 'string') {
154
+ throw new ValidationError('Image path is required and must be a string');
155
+ }
156
+
157
+ // Check if file exists
158
+ try {
159
+ await fs.access(imagePath);
160
+ } catch {
161
+ throw new NotFoundError('Image file', imagePath);
162
+ }
163
+ },
164
+
165
+ /**
166
+ * Validates page creation data. Requires a title and either html or mobiledoc content.
167
+ * Also validates status values and delegates to validateScheduledStatus for scheduling.
168
+ * @param {Object} pageData - The page data to validate
169
+ * @param {string} pageData.title - Page title (required, non-empty)
170
+ * @param {string} [pageData.html] - HTML content (required if mobiledoc not provided)
171
+ * @param {string} [pageData.mobiledoc] - Mobiledoc content (required if html not provided)
172
+ * @param {string} [pageData.status] - Page status ('draft', 'published', 'scheduled')
173
+ * @param {string} [pageData.published_at] - ISO 8601 date for scheduled pages
174
+ * @throws {ValidationError} If validation fails
175
+ */
176
+ validatePageData(pageData) {
177
+ const errors = [];
178
+
179
+ if (!pageData.title || pageData.title.trim().length === 0) {
180
+ errors.push({ field: 'title', message: 'Title is required' });
181
+ }
182
+
183
+ if (!pageData.html && !pageData.mobiledoc) {
184
+ errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
185
+ }
186
+
187
+ if (pageData.status && !['draft', 'published', 'scheduled'].includes(pageData.status)) {
188
+ errors.push({
189
+ field: 'status',
190
+ message: 'Invalid status. Must be draft, published, or scheduled',
191
+ });
192
+ }
193
+
194
+ if (errors.length > 0) {
195
+ throw new ValidationError('Page validation failed', errors);
196
+ }
197
+
198
+ this.validateScheduledStatus(pageData, 'Page');
199
+ },
200
+
201
+ /**
202
+ * Validates newsletter creation data. Requires a non-empty name.
203
+ * @param {Object} newsletterData - The newsletter data to validate
204
+ * @param {string} newsletterData.name - Newsletter name (required, non-empty)
205
+ * @throws {ValidationError} If validation fails
206
+ */
207
+ validateNewsletterData(newsletterData) {
208
+ const errors = [];
209
+
210
+ if (!newsletterData.name || newsletterData.name.trim().length === 0) {
211
+ errors.push({ field: 'name', message: 'Newsletter name is required' });
212
+ }
213
+
214
+ if (errors.length > 0) {
215
+ throw new ValidationError('Newsletter validation failed', errors);
216
+ }
217
+ },
218
+ };
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ import {
7
+ resolveLocalImagePath,
8
+ decodeBase64ToTempFile,
9
+ MAX_BASE64_BYTES,
10
+ } from '../imageInputResolver.js';
11
+
12
+ const tmpRoot = path.join(os.tmpdir(), `img-resolver-${Date.now()}`);
13
+ const allowedRoot = path.join(tmpRoot, 'allowed');
14
+ const outside = path.join(tmpRoot, 'outside');
15
+
16
+ beforeAll(async () => {
17
+ await fs.mkdir(allowedRoot, { recursive: true });
18
+ await fs.mkdir(outside, { recursive: true });
19
+ await fs.writeFile(path.join(allowedRoot, 'ok.png'), 'fake-png');
20
+ await fs.writeFile(path.join(outside, 'secret.txt'), 'top secret');
21
+ await fs.symlink(path.join(outside, 'secret.txt'), path.join(allowedRoot, 'escape.txt'));
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await fs.rm(tmpRoot, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('resolveLocalImagePath', () => {
29
+ const origRoot = process.env.GHOST_MCP_IMAGE_ROOT;
30
+
31
+ beforeEach(() => {
32
+ process.env.GHOST_MCP_IMAGE_ROOT = allowedRoot;
33
+ });
34
+
35
+ afterEach(() => {
36
+ if (origRoot === undefined) delete process.env.GHOST_MCP_IMAGE_ROOT;
37
+ else process.env.GHOST_MCP_IMAGE_ROOT = origRoot;
38
+ });
39
+
40
+ it('accepts a file inside the configured root', async () => {
41
+ const resolved = await resolveLocalImagePath(path.join(allowedRoot, 'ok.png'));
42
+ // Returns the realpath, which on macOS canonicalizes /var -> /private/var.
43
+ const canonical = await fs.realpath(path.join(allowedRoot, 'ok.png'));
44
+ expect(resolved).toBe(canonical);
45
+ });
46
+
47
+ it('rejects when GHOST_MCP_IMAGE_ROOT is unset (local-path mode disabled by default)', async () => {
48
+ delete process.env.GHOST_MCP_IMAGE_ROOT;
49
+ await expect(resolveLocalImagePath('/anywhere.png')).rejects.toThrow(
50
+ /GHOST_MCP_IMAGE_ROOT is not set/
51
+ );
52
+ });
53
+
54
+ it('rejects a path outside the configured root', async () => {
55
+ await expect(resolveLocalImagePath(path.join(outside, 'secret.txt'))).rejects.toThrow(
56
+ /outside the allowed root/
57
+ );
58
+ });
59
+
60
+ it('rejects a relative traversal attempt', async () => {
61
+ await expect(resolveLocalImagePath(`${allowedRoot}/../outside/secret.txt`)).rejects.toThrow(
62
+ /outside the allowed root/
63
+ );
64
+ });
65
+
66
+ it('rejects a symlink that escapes the allowed root', async () => {
67
+ await expect(resolveLocalImagePath(path.join(allowedRoot, 'escape.txt'))).rejects.toThrow(
68
+ /symlink escapes/
69
+ );
70
+ });
71
+
72
+ it('rejects a non-existent file', async () => {
73
+ await expect(resolveLocalImagePath(path.join(allowedRoot, 'nope.png'))).rejects.toThrow(
74
+ /does not exist/
75
+ );
76
+ });
77
+ });
78
+
79
+ describe('decodeBase64ToTempFile', () => {
80
+ it('decodes a plain base64 string to a temp file', async () => {
81
+ const payload = Buffer.from('hello png');
82
+ const out = await decodeBase64ToTempFile(payload.toString('base64'), 'image/png');
83
+ try {
84
+ expect(path.extname(out)).toBe('.png');
85
+ const written = await fs.readFile(out);
86
+ expect(written.equals(payload)).toBe(true);
87
+ } finally {
88
+ await fs.unlink(out).catch(() => {});
89
+ }
90
+ });
91
+
92
+ it('accepts a full data: URI and ignores the prefix', async () => {
93
+ const payload = Buffer.from('svg-bytes');
94
+ const dataUri = `data:image/svg+xml;base64,${payload.toString('base64')}`;
95
+ const out = await decodeBase64ToTempFile(dataUri, 'image/svg+xml');
96
+ try {
97
+ expect(path.extname(out)).toBe('.svg');
98
+ const written = await fs.readFile(out);
99
+ expect(written.equals(payload)).toBe(true);
100
+ } finally {
101
+ await fs.unlink(out).catch(() => {});
102
+ }
103
+ });
104
+
105
+ it('rejects a payload larger than the 5MB cap', async () => {
106
+ const big = Buffer.alloc(MAX_BASE64_BYTES + 1, 0x41);
107
+ await expect(decodeBase64ToTempFile(big.toString('base64'), 'image/png')).rejects.toThrow(
108
+ /exceeds the 5MB/
109
+ );
110
+ });
111
+
112
+ it('rejects an unsupported mimeType', async () => {
113
+ const payload = Buffer.from('x');
114
+ await expect(
115
+ decodeBase64ToTempFile(payload.toString('base64'), 'application/zip')
116
+ ).rejects.toThrow(/Unsupported mimeType/);
117
+ });
118
+
119
+ it('rejects invalid base64 input', async () => {
120
+ await expect(decodeBase64ToTempFile('not base64 at all!!!', 'image/png')).rejects.toThrow(
121
+ /Invalid base64/
122
+ );
123
+ });
124
+
125
+ it('maps image/jpeg to .jpg extension', async () => {
126
+ const payload = Buffer.from([0xff, 0xd8, 0xff]);
127
+ const out = await decodeBase64ToTempFile(payload.toString('base64'), 'image/jpeg');
128
+ try {
129
+ expect(path.extname(out)).toBe('.jpg');
130
+ } finally {
131
+ await fs.unlink(out).catch(() => {});
132
+ }
133
+ });
134
+ });