@jgardner04/ghost-mcp-server 1.10.0 → 1.12.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.
@@ -792,10 +792,7 @@ export async function deleteTag(tagId) {
792
792
  * @throws {GhostAPIError} If the API request fails
793
793
  */
794
794
  export async function createMember(memberData, options = {}) {
795
- // Import and validate input
796
- const { validateMemberData } = await import('./memberService.js');
797
- validateMemberData(memberData);
798
-
795
+ // Input validation is performed at the MCP tool layer using Zod schemas
799
796
  try {
800
797
  return await handleApiRequest('members', 'add', memberData, options);
801
798
  } catch (error) {
@@ -825,14 +822,11 @@ export async function createMember(memberData, options = {}) {
825
822
  * @throws {GhostAPIError} If the API request fails
826
823
  */
827
824
  export async function updateMember(memberId, updateData, options = {}) {
825
+ // Input validation is performed at the MCP tool layer using Zod schemas
828
826
  if (!memberId) {
829
827
  throw new ValidationError('Member ID is required for update');
830
828
  }
831
829
 
832
- // Import and validate update data
833
- const { validateMemberUpdateData } = await import('./memberService.js');
834
- validateMemberUpdateData(updateData);
835
-
836
830
  try {
837
831
  // Get existing member to retrieve updated_at for conflict resolution
838
832
  const existingMember = await handleApiRequest('members', 'read', { id: memberId });
@@ -889,10 +883,7 @@ export async function deleteMember(memberId) {
889
883
  * @throws {GhostAPIError} If the API request fails
890
884
  */
891
885
  export async function getMembers(options = {}) {
892
- // Import and validate query options
893
- const { validateMemberQueryOptions } = await import('./memberService.js');
894
- validateMemberQueryOptions(options);
895
-
886
+ // Input validation is performed at the MCP tool layer using Zod schemas
896
887
  const defaultOptions = {
897
888
  limit: 15,
898
889
  ...options,
@@ -918,12 +909,12 @@ export async function getMembers(options = {}) {
918
909
  * @throws {GhostAPIError} If the API request fails
919
910
  */
920
911
  export async function getMember(params) {
921
- // Import and validate lookup parameters
922
- const { validateMemberLookup, sanitizeNqlValue } = await import('./memberService.js');
923
- const { lookupType, id, email } = validateMemberLookup(params);
912
+ // Input validation is performed at the MCP tool layer using Zod schemas
913
+ const { sanitizeNqlValue } = await import('./memberService.js');
914
+ const { id, email } = params;
924
915
 
925
916
  try {
926
- if (lookupType === 'id') {
917
+ if (id) {
927
918
  // Lookup by ID using read endpoint
928
919
  return await handleApiRequest('members', 'read', { id }, { id });
929
920
  } else {
@@ -960,11 +951,9 @@ export async function getMember(params) {
960
951
  * @throws {GhostAPIError} If the API request fails
961
952
  */
962
953
  export async function searchMembers(query, options = {}) {
963
- // Import and validate search query and options
964
- const { validateSearchQuery, validateSearchOptions, sanitizeNqlValue } =
965
- await import('./memberService.js');
966
- validateSearchOptions(options);
967
- const sanitizedQuery = sanitizeNqlValue(validateSearchQuery(query));
954
+ // Input validation is performed at the MCP tool layer using Zod schemas
955
+ const { sanitizeNqlValue } = await import('./memberService.js');
956
+ const sanitizedQuery = sanitizeNqlValue(query.trim());
968
957
 
969
958
  const limit = options.limit || 15;
970
959
 
@@ -1078,6 +1067,131 @@ export async function deleteNewsletter(newsletterId) {
1078
1067
  }
1079
1068
  }
1080
1069
 
1070
+ /**
1071
+ * Create a new tier (membership level)
1072
+ * @param {Object} tierData - Tier data
1073
+ * @param {Object} [options={}] - Options for the API request
1074
+ * @returns {Promise<Object>} Created tier
1075
+ */
1076
+ export async function createTier(tierData, options = {}) {
1077
+ const { validateTierData } = await import('./tierService.js');
1078
+ validateTierData(tierData);
1079
+
1080
+ try {
1081
+ return await handleApiRequest('tiers', 'add', tierData, options);
1082
+ } catch (error) {
1083
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
1084
+ throw new ValidationError('Tier creation failed due to validation errors', [
1085
+ { field: 'tier', message: error.originalError },
1086
+ ]);
1087
+ }
1088
+ throw error;
1089
+ }
1090
+ }
1091
+
1092
+ /**
1093
+ * Update an existing tier
1094
+ * @param {string} id - Tier ID
1095
+ * @param {Object} updateData - Tier update data
1096
+ * @param {Object} [options={}] - Options for the API request
1097
+ * @returns {Promise<Object>} Updated tier
1098
+ */
1099
+ export async function updateTier(id, updateData, options = {}) {
1100
+ if (!id || typeof id !== 'string' || id.trim().length === 0) {
1101
+ throw new ValidationError('Tier ID is required for update');
1102
+ }
1103
+
1104
+ const { validateTierUpdateData } = await import('./tierService.js');
1105
+ validateTierUpdateData(updateData);
1106
+
1107
+ try {
1108
+ // Get existing tier for merge
1109
+ const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
1110
+
1111
+ // Merge updates with existing data
1112
+ const mergedData = {
1113
+ ...existingTier,
1114
+ ...updateData,
1115
+ updated_at: existingTier.updated_at,
1116
+ };
1117
+
1118
+ return await handleApiRequest('tiers', 'edit', mergedData, { id, ...options });
1119
+ } catch (error) {
1120
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1121
+ throw new NotFoundError('Tier', id);
1122
+ }
1123
+ throw error;
1124
+ }
1125
+ }
1126
+
1127
+ /**
1128
+ * Delete a tier
1129
+ * @param {string} id - Tier ID
1130
+ * @returns {Promise<Object>} Deletion result
1131
+ */
1132
+ export async function deleteTier(id) {
1133
+ if (!id || typeof id !== 'string' || id.trim().length === 0) {
1134
+ throw new ValidationError('Tier ID is required for deletion');
1135
+ }
1136
+
1137
+ try {
1138
+ return await handleApiRequest('tiers', 'delete', { id });
1139
+ } catch (error) {
1140
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1141
+ throw new NotFoundError('Tier', id);
1142
+ }
1143
+ throw error;
1144
+ }
1145
+ }
1146
+
1147
+ /**
1148
+ * Get all tiers with optional filtering
1149
+ * @param {Object} [options={}] - Query options
1150
+ * @param {number} [options.limit] - Number of tiers to return (1-100, default 15)
1151
+ * @param {number} [options.page] - Page number
1152
+ * @param {string} [options.filter] - NQL filter string (e.g., "type:paid", "type:free")
1153
+ * @param {string} [options.order] - Order string
1154
+ * @param {string} [options.include] - Include string
1155
+ * @returns {Promise<Array>} Array of tiers
1156
+ */
1157
+ export async function getTiers(options = {}) {
1158
+ const { validateTierQueryOptions } = await import('./tierService.js');
1159
+ validateTierQueryOptions(options);
1160
+
1161
+ const defaultOptions = {
1162
+ limit: 15,
1163
+ ...options,
1164
+ };
1165
+
1166
+ try {
1167
+ const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions);
1168
+ return tiers || [];
1169
+ } catch (error) {
1170
+ console.error('Failed to get tiers:', error);
1171
+ throw error;
1172
+ }
1173
+ }
1174
+
1175
+ /**
1176
+ * Get a single tier by ID
1177
+ * @param {string} id - Tier ID
1178
+ * @returns {Promise<Object>} Tier object
1179
+ */
1180
+ export async function getTier(id) {
1181
+ if (!id || typeof id !== 'string' || id.trim().length === 0) {
1182
+ throw new ValidationError('Tier ID is required and must be a non-empty string');
1183
+ }
1184
+
1185
+ try {
1186
+ return await handleApiRequest('tiers', 'read', { id }, { id });
1187
+ } catch (error) {
1188
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1189
+ throw new NotFoundError('Tier', id);
1190
+ }
1191
+ throw error;
1192
+ }
1193
+ }
1194
+
1081
1195
  /**
1082
1196
  * Health check for Ghost API connection
1083
1197
  */
@@ -1140,5 +1254,10 @@ export default {
1140
1254
  createNewsletter,
1141
1255
  updateNewsletter,
1142
1256
  deleteNewsletter,
1257
+ createTier,
1258
+ updateTier,
1259
+ deleteTier,
1260
+ getTiers,
1261
+ getTier,
1143
1262
  checkHealth,
1144
1263
  };
@@ -1,12 +1,10 @@
1
1
  import sanitizeHtml from 'sanitize-html';
2
- import Joi from 'joi';
3
2
  import { createContextLogger } from '../utils/logger.js';
4
3
  import {
5
4
  createPost as createGhostPost,
6
5
  getTags as getGhostTags,
7
6
  createTag as createGhostTag,
8
- // Import other necessary functions from ghostService later
9
- } from './ghostService.js'; // Note the relative path
7
+ } from './ghostService.js';
10
8
 
11
9
  /**
12
10
  * Helper to generate a simple meta description from HTML content.
@@ -37,37 +35,13 @@ const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => {
37
35
  /**
38
36
  * Service layer function to handle the business logic of creating a post.
39
37
  * Transforms input data, handles/resolves tags, includes feature image and metadata.
40
- * @param {object} postInput - Data received from the controller.
38
+ * Input validation is handled at the MCP layer using Zod schemas.
39
+ * @param {object} postInput - Validated data received from the MCP tool.
41
40
  * @returns {Promise<object>} The created post object from the Ghost API.
42
41
  */
43
- // Validation schema for post input
44
- const postInputSchema = Joi.object({
45
- title: Joi.string().max(255).required(),
46
- html: Joi.string().required(),
47
- custom_excerpt: Joi.string().max(500).optional(),
48
- status: Joi.string().valid('draft', 'published', 'scheduled').optional(),
49
- published_at: Joi.string().isoDate().optional(),
50
- tags: Joi.array().items(Joi.string().max(50)).max(10).optional(),
51
- feature_image: Joi.string().uri().optional(),
52
- feature_image_alt: Joi.string().max(255).optional(),
53
- feature_image_caption: Joi.string().max(500).optional(),
54
- meta_title: Joi.string().max(70).optional(),
55
- meta_description: Joi.string().max(160).optional(),
56
- });
57
-
58
42
  const createPostService = async (postInput) => {
59
43
  const logger = createContextLogger('post-service');
60
44
 
61
- // Validate input to prevent format string vulnerabilities
62
- const { error, value: validatedInput } = postInputSchema.validate(postInput);
63
- if (error) {
64
- logger.error('Post input validation failed', {
65
- error: error.details[0].message,
66
- inputKeys: Object.keys(postInput),
67
- });
68
- throw new Error(`Invalid post input: ${error.details[0].message}`);
69
- }
70
-
71
45
  const {
72
46
  title,
73
47
  html,
@@ -80,7 +54,7 @@ const createPostService = async (postInput) => {
80
54
  feature_image_caption,
81
55
  meta_title,
82
56
  meta_description,
83
- } = validatedInput;
57
+ } = postInput;
84
58
 
85
59
  // --- Resolve Tag Names to Tag Objects (ID/Slug/Name) ---
86
60
  let resolvedTags = [];
@@ -0,0 +1,304 @@
1
+ import { ValidationError } from '../errors/index.js';
2
+
3
+ /**
4
+ * Maximum length constants (following Ghost's database constraints)
5
+ */
6
+ const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit
7
+ const MAX_DESCRIPTION_LENGTH = 2000; // Reasonable limit for descriptions
8
+
9
+ /**
10
+ * Query constraints for tier browsing
11
+ */
12
+ const MIN_LIMIT = 1;
13
+ const MAX_LIMIT = 100;
14
+ const MIN_PAGE = 1;
15
+
16
+ /**
17
+ * Currency code validation regex (3-letter uppercase)
18
+ */
19
+ const CURRENCY_REGEX = /^[A-Z]{3}$/;
20
+
21
+ /**
22
+ * URL validation regex (simple HTTP/HTTPS validation)
23
+ */
24
+ const URL_REGEX = /^https?:\/\/.+/i;
25
+
26
+ /**
27
+ * Validates tier data for creation
28
+ * @param {Object} tierData - The tier data to validate
29
+ * @throws {ValidationError} If validation fails
30
+ */
31
+ export function validateTierData(tierData) {
32
+ const errors = [];
33
+
34
+ // Name is required and must be a non-empty string
35
+ if (!tierData.name || typeof tierData.name !== 'string' || tierData.name.trim().length === 0) {
36
+ errors.push({ field: 'name', message: 'Name is required and must be a non-empty string' });
37
+ } else if (tierData.name.length > MAX_NAME_LENGTH) {
38
+ errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
39
+ }
40
+
41
+ // Currency is required and must be a 3-letter uppercase code
42
+ if (!tierData.currency || typeof tierData.currency !== 'string') {
43
+ errors.push({ field: 'currency', message: 'Currency is required' });
44
+ } else if (!CURRENCY_REGEX.test(tierData.currency)) {
45
+ errors.push({
46
+ field: 'currency',
47
+ message: 'Currency must be a 3-letter uppercase code (e.g., USD, EUR)',
48
+ });
49
+ }
50
+
51
+ // Description is optional but must be a string with valid length if provided
52
+ if (tierData.description !== undefined) {
53
+ if (typeof tierData.description !== 'string') {
54
+ errors.push({ field: 'description', message: 'Description must be a string' });
55
+ } else if (tierData.description.length > MAX_DESCRIPTION_LENGTH) {
56
+ errors.push({
57
+ field: 'description',
58
+ message: `Description must not exceed ${MAX_DESCRIPTION_LENGTH} characters`,
59
+ });
60
+ }
61
+ }
62
+
63
+ // Monthly price is optional but must be a non-negative number if provided
64
+ if (tierData.monthly_price !== undefined) {
65
+ if (typeof tierData.monthly_price !== 'number' || tierData.monthly_price < 0) {
66
+ errors.push({
67
+ field: 'monthly_price',
68
+ message: 'Monthly price must be a non-negative number',
69
+ });
70
+ }
71
+ }
72
+
73
+ // Yearly price is optional but must be a non-negative number if provided
74
+ if (tierData.yearly_price !== undefined) {
75
+ if (typeof tierData.yearly_price !== 'number' || tierData.yearly_price < 0) {
76
+ errors.push({
77
+ field: 'yearly_price',
78
+ message: 'Yearly price must be a non-negative number',
79
+ });
80
+ }
81
+ }
82
+
83
+ // Benefits is optional but must be an array of non-empty strings if provided
84
+ if (tierData.benefits !== undefined) {
85
+ if (!Array.isArray(tierData.benefits)) {
86
+ errors.push({ field: 'benefits', message: 'Benefits must be an array' });
87
+ } else {
88
+ // Validate each benefit is a non-empty string
89
+ const invalidBenefits = tierData.benefits.filter(
90
+ (benefit) => typeof benefit !== 'string' || benefit.trim().length === 0
91
+ );
92
+ if (invalidBenefits.length > 0) {
93
+ errors.push({
94
+ field: 'benefits',
95
+ message: 'Each benefit must be a non-empty string',
96
+ });
97
+ }
98
+ }
99
+ }
100
+
101
+ // Welcome page URL is optional but must be a valid HTTP/HTTPS URL if provided
102
+ if (tierData.welcome_page_url !== undefined) {
103
+ if (
104
+ typeof tierData.welcome_page_url !== 'string' ||
105
+ !URL_REGEX.test(tierData.welcome_page_url)
106
+ ) {
107
+ errors.push({
108
+ field: 'welcome_page_url',
109
+ message: 'Welcome page URL must be a valid URL',
110
+ });
111
+ }
112
+ }
113
+
114
+ if (errors.length > 0) {
115
+ throw new ValidationError('Tier validation failed', errors);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Validates tier data for update
121
+ * All fields are optional for updates, but if provided they must be valid
122
+ * @param {Object} updateData - The tier update data to validate
123
+ * @throws {ValidationError} If validation fails
124
+ */
125
+ export function validateTierUpdateData(updateData) {
126
+ const errors = [];
127
+
128
+ // Name is optional for update but must be a non-empty string with valid length if provided
129
+ if (updateData.name !== undefined) {
130
+ if (typeof updateData.name !== 'string' || updateData.name.trim().length === 0) {
131
+ errors.push({ field: 'name', message: 'Name must be a non-empty string' });
132
+ } else if (updateData.name.length > MAX_NAME_LENGTH) {
133
+ errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
134
+ }
135
+ }
136
+
137
+ // Currency is optional for update but must be a 3-letter uppercase code if provided
138
+ if (updateData.currency !== undefined) {
139
+ if (typeof updateData.currency !== 'string' || !CURRENCY_REGEX.test(updateData.currency)) {
140
+ errors.push({
141
+ field: 'currency',
142
+ message: 'Currency must be a 3-letter uppercase code (e.g., USD, EUR)',
143
+ });
144
+ }
145
+ }
146
+
147
+ // Description is optional but must be a string with valid length if provided
148
+ if (updateData.description !== undefined) {
149
+ if (typeof updateData.description !== 'string') {
150
+ errors.push({ field: 'description', message: 'Description must be a string' });
151
+ } else if (updateData.description.length > MAX_DESCRIPTION_LENGTH) {
152
+ errors.push({
153
+ field: 'description',
154
+ message: `Description must not exceed ${MAX_DESCRIPTION_LENGTH} characters`,
155
+ });
156
+ }
157
+ }
158
+
159
+ // Monthly price is optional but must be a non-negative number if provided
160
+ if (updateData.monthly_price !== undefined) {
161
+ if (typeof updateData.monthly_price !== 'number' || updateData.monthly_price < 0) {
162
+ errors.push({
163
+ field: 'monthly_price',
164
+ message: 'Monthly price must be a non-negative number',
165
+ });
166
+ }
167
+ }
168
+
169
+ // Yearly price is optional but must be a non-negative number if provided
170
+ if (updateData.yearly_price !== undefined) {
171
+ if (typeof updateData.yearly_price !== 'number' || updateData.yearly_price < 0) {
172
+ errors.push({
173
+ field: 'yearly_price',
174
+ message: 'Yearly price must be a non-negative number',
175
+ });
176
+ }
177
+ }
178
+
179
+ // Benefits is optional but must be an array of non-empty strings if provided
180
+ if (updateData.benefits !== undefined) {
181
+ if (!Array.isArray(updateData.benefits)) {
182
+ errors.push({ field: 'benefits', message: 'Benefits must be an array' });
183
+ } else {
184
+ // Validate each benefit is a non-empty string
185
+ const invalidBenefits = updateData.benefits.filter(
186
+ (benefit) => typeof benefit !== 'string' || benefit.trim().length === 0
187
+ );
188
+ if (invalidBenefits.length > 0) {
189
+ errors.push({
190
+ field: 'benefits',
191
+ message: 'Each benefit must be a non-empty string',
192
+ });
193
+ }
194
+ }
195
+ }
196
+
197
+ // Welcome page URL is optional but must be a valid HTTP/HTTPS URL if provided
198
+ if (updateData.welcome_page_url !== undefined) {
199
+ if (
200
+ typeof updateData.welcome_page_url !== 'string' ||
201
+ !URL_REGEX.test(updateData.welcome_page_url)
202
+ ) {
203
+ errors.push({
204
+ field: 'welcome_page_url',
205
+ message: 'Welcome page URL must be a valid URL',
206
+ });
207
+ }
208
+ }
209
+
210
+ if (errors.length > 0) {
211
+ throw new ValidationError('Tier validation failed', errors);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Validates query options for tier browsing
217
+ * @param {Object} options - The query options to validate
218
+ * @param {number} [options.limit] - Number of tiers to return (1-100)
219
+ * @param {number} [options.page] - Page number (1+)
220
+ * @param {string} [options.filter] - NQL filter string
221
+ * @param {string} [options.order] - Order string (e.g., 'created_at desc')
222
+ * @param {string} [options.include] - Include string (e.g., 'monthly_price,yearly_price')
223
+ * @throws {ValidationError} If validation fails
224
+ */
225
+ export function validateTierQueryOptions(options) {
226
+ const errors = [];
227
+
228
+ // Validate limit
229
+ if (options.limit !== undefined) {
230
+ if (
231
+ typeof options.limit !== 'number' ||
232
+ options.limit < MIN_LIMIT ||
233
+ options.limit > MAX_LIMIT
234
+ ) {
235
+ errors.push({
236
+ field: 'limit',
237
+ message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_LIMIT}`,
238
+ });
239
+ }
240
+ }
241
+
242
+ // Validate page
243
+ if (options.page !== undefined) {
244
+ if (typeof options.page !== 'number' || options.page < MIN_PAGE) {
245
+ errors.push({
246
+ field: 'page',
247
+ message: `Page must be a number >= ${MIN_PAGE}`,
248
+ });
249
+ }
250
+ }
251
+
252
+ // Validate filter (must be non-empty string if provided)
253
+ if (options.filter !== undefined) {
254
+ if (typeof options.filter !== 'string' || options.filter.trim().length === 0) {
255
+ errors.push({
256
+ field: 'filter',
257
+ message: 'Filter must be a non-empty string',
258
+ });
259
+ }
260
+ }
261
+
262
+ // Validate order (must be non-empty string if provided)
263
+ if (options.order !== undefined) {
264
+ if (typeof options.order !== 'string' || options.order.trim().length === 0) {
265
+ errors.push({
266
+ field: 'order',
267
+ message: 'Order must be a non-empty string',
268
+ });
269
+ }
270
+ }
271
+
272
+ // Validate include (must be non-empty string if provided)
273
+ if (options.include !== undefined) {
274
+ if (typeof options.include !== 'string' || options.include.trim().length === 0) {
275
+ errors.push({
276
+ field: 'include',
277
+ message: 'Include must be a non-empty string',
278
+ });
279
+ }
280
+ }
281
+
282
+ if (errors.length > 0) {
283
+ throw new ValidationError('Tier query validation failed', errors);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Sanitizes a value for use in NQL filters to prevent injection
289
+ * Escapes backslashes, single quotes, and double quotes
290
+ * @param {string} value - The value to sanitize
291
+ * @returns {string} The sanitized value
292
+ */
293
+ export function sanitizeNqlValue(value) {
294
+ if (!value) return value;
295
+ // Escape backslashes first, then quotes
296
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
297
+ }
298
+
299
+ export default {
300
+ validateTierData,
301
+ validateTierUpdateData,
302
+ validateTierQueryOptions,
303
+ sanitizeNqlValue,
304
+ };