@jgardner04/ghost-mcp-server 1.7.0 → 1.9.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.
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateMemberData, validateMemberUpdateData } from '../memberService.js';
3
+
4
+ describe('memberService - Validation', () => {
5
+ describe('validateMemberData', () => {
6
+ it('should validate required email field', () => {
7
+ expect(() => validateMemberData({})).toThrow('Member validation failed');
8
+ expect(() => validateMemberData({ email: '' })).toThrow('Member validation failed');
9
+ expect(() => validateMemberData({ email: ' ' })).toThrow('Member validation failed');
10
+ });
11
+
12
+ it('should validate email format', () => {
13
+ expect(() => validateMemberData({ email: 'invalid-email' })).toThrow(
14
+ 'Member validation failed'
15
+ );
16
+ expect(() => validateMemberData({ email: 'test@' })).toThrow('Member validation failed');
17
+ expect(() => validateMemberData({ email: '@test.com' })).toThrow('Member validation failed');
18
+ });
19
+
20
+ it('should accept valid email', () => {
21
+ expect(() => validateMemberData({ email: 'test@example.com' })).not.toThrow();
22
+ });
23
+
24
+ it('should accept optional name field', () => {
25
+ expect(() =>
26
+ validateMemberData({ email: 'test@example.com', name: 'John Doe' })
27
+ ).not.toThrow();
28
+ });
29
+
30
+ it('should accept optional note field', () => {
31
+ expect(() =>
32
+ validateMemberData({ email: 'test@example.com', note: 'Test note' })
33
+ ).not.toThrow();
34
+ });
35
+
36
+ it('should accept optional labels array', () => {
37
+ expect(() =>
38
+ validateMemberData({ email: 'test@example.com', labels: ['premium', 'newsletter'] })
39
+ ).not.toThrow();
40
+ });
41
+
42
+ it('should validate labels is an array', () => {
43
+ expect(() => validateMemberData({ email: 'test@example.com', labels: 'premium' })).toThrow(
44
+ 'Member validation failed'
45
+ );
46
+ });
47
+
48
+ it('should accept optional newsletters array', () => {
49
+ expect(() =>
50
+ validateMemberData({
51
+ email: 'test@example.com',
52
+ newsletters: [{ id: 'newsletter-1' }],
53
+ })
54
+ ).not.toThrow();
55
+ });
56
+
57
+ it('should validate newsletter objects have id field', () => {
58
+ expect(() =>
59
+ validateMemberData({
60
+ email: 'test@example.com',
61
+ newsletters: [{ name: 'Newsletter' }],
62
+ })
63
+ ).toThrow('Member validation failed');
64
+ });
65
+
66
+ it('should accept optional subscribed boolean', () => {
67
+ expect(() =>
68
+ validateMemberData({ email: 'test@example.com', subscribed: true })
69
+ ).not.toThrow();
70
+ expect(() =>
71
+ validateMemberData({ email: 'test@example.com', subscribed: false })
72
+ ).not.toThrow();
73
+ });
74
+
75
+ it('should validate subscribed is a boolean', () => {
76
+ expect(() => validateMemberData({ email: 'test@example.com', subscribed: 'yes' })).toThrow(
77
+ 'Member validation failed'
78
+ );
79
+ });
80
+
81
+ it('should validate name length', () => {
82
+ const longName = 'a'.repeat(192); // Exceeds MAX_NAME_LENGTH (191)
83
+ expect(() => validateMemberData({ email: 'test@example.com', name: longName })).toThrow(
84
+ 'Member validation failed'
85
+ );
86
+ });
87
+
88
+ it('should accept name at max length', () => {
89
+ const maxName = 'a'.repeat(191); // At MAX_NAME_LENGTH
90
+ expect(() => validateMemberData({ email: 'test@example.com', name: maxName })).not.toThrow();
91
+ });
92
+
93
+ it('should validate note length', () => {
94
+ const longNote = 'a'.repeat(2001); // Exceeds MAX_NOTE_LENGTH (2000)
95
+ expect(() => validateMemberData({ email: 'test@example.com', note: longNote })).toThrow(
96
+ 'Member validation failed'
97
+ );
98
+ });
99
+
100
+ it('should accept note at max length', () => {
101
+ const maxNote = 'a'.repeat(2000); // At MAX_NOTE_LENGTH
102
+ expect(() => validateMemberData({ email: 'test@example.com', note: maxNote })).not.toThrow();
103
+ });
104
+
105
+ it('should sanitize HTML in note field', () => {
106
+ const memberData = {
107
+ email: 'test@example.com',
108
+ note: '<script>alert("xss")</script>Test note',
109
+ };
110
+ validateMemberData(memberData);
111
+ expect(memberData.note).toBe('Test note'); // HTML should be stripped
112
+ });
113
+
114
+ it('should validate label length', () => {
115
+ const longLabel = 'a'.repeat(192); // Exceeds MAX_LABEL_LENGTH (191)
116
+ expect(() => validateMemberData({ email: 'test@example.com', labels: [longLabel] })).toThrow(
117
+ 'Member validation failed'
118
+ );
119
+ });
120
+
121
+ it('should reject empty string labels', () => {
122
+ expect(() => validateMemberData({ email: 'test@example.com', labels: [''] })).toThrow(
123
+ 'Member validation failed'
124
+ );
125
+ expect(() => validateMemberData({ email: 'test@example.com', labels: [' '] })).toThrow(
126
+ 'Member validation failed'
127
+ );
128
+ });
129
+
130
+ it('should reject non-string labels', () => {
131
+ expect(() => validateMemberData({ email: 'test@example.com', labels: [123] })).toThrow(
132
+ 'Member validation failed'
133
+ );
134
+ });
135
+
136
+ it('should reject empty newsletter IDs', () => {
137
+ expect(() =>
138
+ validateMemberData({ email: 'test@example.com', newsletters: [{ id: '' }] })
139
+ ).toThrow('Member validation failed');
140
+ expect(() =>
141
+ validateMemberData({ email: 'test@example.com', newsletters: [{ id: ' ' }] })
142
+ ).toThrow('Member validation failed');
143
+ });
144
+ });
145
+
146
+ describe('validateMemberUpdateData', () => {
147
+ it('should validate email format if provided', () => {
148
+ expect(() => validateMemberUpdateData({ email: 'invalid-email' })).toThrow(
149
+ 'Member validation failed'
150
+ );
151
+ expect(() => validateMemberUpdateData({ email: 'test@example.com' })).not.toThrow();
152
+ });
153
+
154
+ it('should accept update with only name', () => {
155
+ expect(() => validateMemberUpdateData({ name: 'John Doe' })).not.toThrow();
156
+ });
157
+
158
+ it('should accept update with only note', () => {
159
+ expect(() => validateMemberUpdateData({ note: 'Updated note' })).not.toThrow();
160
+ });
161
+
162
+ it('should accept update with only labels', () => {
163
+ expect(() => validateMemberUpdateData({ labels: ['premium'] })).not.toThrow();
164
+ });
165
+
166
+ it('should validate labels is an array if provided', () => {
167
+ expect(() => validateMemberUpdateData({ labels: 'premium' })).toThrow(
168
+ 'Member validation failed'
169
+ );
170
+ });
171
+
172
+ it('should accept update with only newsletters', () => {
173
+ expect(() =>
174
+ validateMemberUpdateData({ newsletters: [{ id: 'newsletter-1' }] })
175
+ ).not.toThrow();
176
+ });
177
+
178
+ it('should validate newsletter objects have id field if provided', () => {
179
+ expect(() => validateMemberUpdateData({ newsletters: [{ name: 'Newsletter' }] })).toThrow(
180
+ 'Member validation failed'
181
+ );
182
+ });
183
+
184
+ it('should allow empty update object', () => {
185
+ expect(() => validateMemberUpdateData({})).not.toThrow();
186
+ });
187
+
188
+ it('should validate name length in updates', () => {
189
+ const longName = 'a'.repeat(192); // Exceeds MAX_NAME_LENGTH (191)
190
+ expect(() => validateMemberUpdateData({ name: longName })).toThrow(
191
+ 'Member validation failed'
192
+ );
193
+ });
194
+
195
+ it('should accept name at max length in updates', () => {
196
+ const maxName = 'a'.repeat(191); // At MAX_NAME_LENGTH
197
+ expect(() => validateMemberUpdateData({ name: maxName })).not.toThrow();
198
+ });
199
+
200
+ it('should validate note length in updates', () => {
201
+ const longNote = 'a'.repeat(2001); // Exceeds MAX_NOTE_LENGTH (2000)
202
+ expect(() => validateMemberUpdateData({ note: longNote })).toThrow(
203
+ 'Member validation failed'
204
+ );
205
+ });
206
+
207
+ it('should accept note at max length in updates', () => {
208
+ const maxNote = 'a'.repeat(2000); // At MAX_NOTE_LENGTH
209
+ expect(() => validateMemberUpdateData({ note: maxNote })).not.toThrow();
210
+ });
211
+
212
+ it('should sanitize HTML in note field for updates', () => {
213
+ const updateData = { note: '<script>alert("xss")</script>Updated note' };
214
+ validateMemberUpdateData(updateData);
215
+ expect(updateData.note).toBe('Updated note'); // HTML should be stripped
216
+ });
217
+
218
+ it('should validate label length in updates', () => {
219
+ const longLabel = 'a'.repeat(192); // Exceeds MAX_LABEL_LENGTH (191)
220
+ expect(() => validateMemberUpdateData({ labels: [longLabel] })).toThrow(
221
+ 'Member validation failed'
222
+ );
223
+ });
224
+
225
+ it('should reject empty string labels in updates', () => {
226
+ expect(() => validateMemberUpdateData({ labels: [''] })).toThrow('Member validation failed');
227
+ expect(() => validateMemberUpdateData({ labels: [' '] })).toThrow(
228
+ 'Member validation failed'
229
+ );
230
+ });
231
+
232
+ it('should reject non-string labels in updates', () => {
233
+ expect(() => validateMemberUpdateData({ labels: [123] })).toThrow('Member validation failed');
234
+ });
235
+
236
+ it('should reject empty newsletter IDs in updates', () => {
237
+ expect(() => validateMemberUpdateData({ newsletters: [{ id: '' }] })).toThrow(
238
+ 'Member validation failed'
239
+ );
240
+ expect(() => validateMemberUpdateData({ newsletters: [{ id: ' ' }] })).toThrow(
241
+ 'Member validation failed'
242
+ );
243
+ });
244
+ });
245
+ });
@@ -772,6 +772,110 @@ export async function deleteTag(tagId) {
772
772
  }
773
773
  }
774
774
 
775
+ /**
776
+ * Member CRUD Operations
777
+ * Members represent subscribers/users in Ghost CMS
778
+ */
779
+
780
+ /**
781
+ * Creates a new member (subscriber) in Ghost CMS
782
+ * @param {Object} memberData - The member data
783
+ * @param {string} memberData.email - Member email (required)
784
+ * @param {string} [memberData.name] - Member name
785
+ * @param {string} [memberData.note] - Notes about the member (HTML will be sanitized)
786
+ * @param {string[]} [memberData.labels] - Array of label names
787
+ * @param {Object[]} [memberData.newsletters] - Array of newsletter objects with id
788
+ * @param {boolean} [memberData.subscribed] - Email subscription status
789
+ * @param {Object} [options] - Additional options for the API request
790
+ * @returns {Promise<Object>} The created member object
791
+ * @throws {ValidationError} If validation fails
792
+ * @throws {GhostAPIError} If the API request fails
793
+ */
794
+ export async function createMember(memberData, options = {}) {
795
+ // Import and validate input
796
+ const { validateMemberData } = await import('./memberService.js');
797
+ validateMemberData(memberData);
798
+
799
+ try {
800
+ return await handleApiRequest('members', 'add', memberData, options);
801
+ } catch (error) {
802
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
803
+ throw new ValidationError('Member creation failed due to validation errors', [
804
+ { field: 'member', message: error.originalError },
805
+ ]);
806
+ }
807
+ throw error;
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Updates an existing member in Ghost CMS
813
+ * @param {string} memberId - The member ID to update
814
+ * @param {Object} updateData - The member update data
815
+ * @param {string} [updateData.email] - Member email
816
+ * @param {string} [updateData.name] - Member name
817
+ * @param {string} [updateData.note] - Notes about the member (HTML will be sanitized)
818
+ * @param {string[]} [updateData.labels] - Array of label names
819
+ * @param {Object[]} [updateData.newsletters] - Array of newsletter objects with id
820
+ * @param {boolean} [updateData.subscribed] - Email subscription status
821
+ * @param {Object} [options] - Additional options for the API request
822
+ * @returns {Promise<Object>} The updated member object
823
+ * @throws {ValidationError} If validation fails
824
+ * @throws {NotFoundError} If the member is not found
825
+ * @throws {GhostAPIError} If the API request fails
826
+ */
827
+ export async function updateMember(memberId, updateData, options = {}) {
828
+ if (!memberId) {
829
+ throw new ValidationError('Member ID is required for update');
830
+ }
831
+
832
+ // Import and validate update data
833
+ const { validateMemberUpdateData } = await import('./memberService.js');
834
+ validateMemberUpdateData(updateData);
835
+
836
+ try {
837
+ // Get existing member to retrieve updated_at for conflict resolution
838
+ const existingMember = await handleApiRequest('members', 'read', { id: memberId });
839
+
840
+ // Merge existing data with updates, preserving updated_at
841
+ const mergedData = {
842
+ ...existingMember,
843
+ ...updateData,
844
+ updated_at: existingMember.updated_at,
845
+ };
846
+
847
+ return await handleApiRequest('members', 'edit', mergedData, { id: memberId, ...options });
848
+ } catch (error) {
849
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
850
+ throw new NotFoundError('Member', memberId);
851
+ }
852
+ throw error;
853
+ }
854
+ }
855
+
856
+ /**
857
+ * Deletes a member from Ghost CMS
858
+ * @param {string} memberId - The member ID to delete
859
+ * @returns {Promise<Object>} Deletion confirmation object
860
+ * @throws {ValidationError} If member ID is not provided
861
+ * @throws {NotFoundError} If the member is not found
862
+ * @throws {GhostAPIError} If the API request fails
863
+ */
864
+ export async function deleteMember(memberId) {
865
+ if (!memberId) {
866
+ throw new ValidationError('Member ID is required for deletion');
867
+ }
868
+
869
+ try {
870
+ return await handleApiRequest('members', 'delete', { id: memberId });
871
+ } catch (error) {
872
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
873
+ throw new NotFoundError('Member', memberId);
874
+ }
875
+ throw error;
876
+ }
877
+ }
878
+
775
879
  /**
776
880
  * Newsletter CRUD Operations
777
881
  */
@@ -920,6 +1024,9 @@ export default {
920
1024
  getTag,
921
1025
  updateTag,
922
1026
  deleteTag,
1027
+ createMember,
1028
+ updateMember,
1029
+ deleteMember,
923
1030
  getNewsletters,
924
1031
  getNewsletter,
925
1032
  createNewsletter,
@@ -0,0 +1,202 @@
1
+ import sanitizeHtml from 'sanitize-html';
2
+ import { ValidationError } from '../errors/index.js';
3
+
4
+ /**
5
+ * Email validation regex
6
+ * Simple but effective email validation
7
+ */
8
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
9
+
10
+ /**
11
+ * Maximum length constants (following Ghost's database constraints)
12
+ */
13
+ const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit
14
+ const MAX_NOTE_LENGTH = 2000; // Reasonable limit for notes
15
+ const MAX_LABEL_LENGTH = 191; // Label name limit
16
+
17
+ /**
18
+ * Validates member data for creation
19
+ * @param {Object} memberData - The member data to validate
20
+ * @throws {ValidationError} If validation fails
21
+ */
22
+ export function validateMemberData(memberData) {
23
+ const errors = [];
24
+
25
+ // Email is required and must be valid
26
+ if (!memberData.email || memberData.email.trim().length === 0) {
27
+ errors.push({ field: 'email', message: 'Email is required' });
28
+ } else if (!EMAIL_REGEX.test(memberData.email)) {
29
+ errors.push({ field: 'email', message: 'Invalid email format' });
30
+ }
31
+
32
+ // Name is optional but must be a string with valid length if provided
33
+ if (memberData.name !== undefined) {
34
+ if (typeof memberData.name !== 'string') {
35
+ errors.push({ field: 'name', message: 'Name must be a string' });
36
+ } else if (memberData.name.length > MAX_NAME_LENGTH) {
37
+ errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
38
+ }
39
+ }
40
+
41
+ // Note is optional but must be a string with valid length if provided
42
+ // Sanitize HTML to prevent XSS attacks
43
+ if (memberData.note !== undefined) {
44
+ if (typeof memberData.note !== 'string') {
45
+ errors.push({ field: 'note', message: 'Note must be a string' });
46
+ } else {
47
+ if (memberData.note.length > MAX_NOTE_LENGTH) {
48
+ errors.push({
49
+ field: 'note',
50
+ message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`,
51
+ });
52
+ }
53
+ // Sanitize HTML content - strip all HTML tags for notes
54
+ memberData.note = sanitizeHtml(memberData.note, {
55
+ allowedTags: [], // Strip all HTML
56
+ allowedAttributes: {},
57
+ });
58
+ }
59
+ }
60
+
61
+ // Labels is optional but must be an array of valid strings if provided
62
+ if (memberData.labels !== undefined) {
63
+ if (!Array.isArray(memberData.labels)) {
64
+ errors.push({ field: 'labels', message: 'Labels must be an array' });
65
+ } else {
66
+ // Validate each label is a non-empty string within length limit
67
+ const invalidLabels = memberData.labels.filter(
68
+ (label) =>
69
+ typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH
70
+ );
71
+ if (invalidLabels.length > 0) {
72
+ errors.push({
73
+ field: 'labels',
74
+ message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`,
75
+ });
76
+ }
77
+ }
78
+ }
79
+
80
+ // Newsletters is optional but must be an array of objects with valid id field
81
+ if (memberData.newsletters !== undefined) {
82
+ if (!Array.isArray(memberData.newsletters)) {
83
+ errors.push({ field: 'newsletters', message: 'Newsletters must be an array' });
84
+ } else {
85
+ // Validate each newsletter has a non-empty string id
86
+ const invalidNewsletters = memberData.newsletters.filter(
87
+ (newsletter) =>
88
+ !newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0
89
+ );
90
+ if (invalidNewsletters.length > 0) {
91
+ errors.push({
92
+ field: 'newsletters',
93
+ message: 'Each newsletter must have a non-empty id field',
94
+ });
95
+ }
96
+ }
97
+ }
98
+
99
+ // Subscribed is optional but must be a boolean if provided
100
+ if (memberData.subscribed !== undefined && typeof memberData.subscribed !== 'boolean') {
101
+ errors.push({ field: 'subscribed', message: 'Subscribed must be a boolean' });
102
+ }
103
+
104
+ if (errors.length > 0) {
105
+ throw new ValidationError('Member validation failed', errors);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Validates member data for update
111
+ * All fields are optional for updates, but if provided they must be valid
112
+ * @param {Object} updateData - The member update data to validate
113
+ * @throws {ValidationError} If validation fails
114
+ */
115
+ export function validateMemberUpdateData(updateData) {
116
+ const errors = [];
117
+
118
+ // Email is optional for update but must be valid if provided
119
+ if (updateData.email !== undefined) {
120
+ if (typeof updateData.email !== 'string' || updateData.email.trim().length === 0) {
121
+ errors.push({ field: 'email', message: 'Email must be a non-empty string' });
122
+ } else if (!EMAIL_REGEX.test(updateData.email)) {
123
+ errors.push({ field: 'email', message: 'Invalid email format' });
124
+ }
125
+ }
126
+
127
+ // Name is optional but must be a string with valid length if provided
128
+ if (updateData.name !== undefined) {
129
+ if (typeof updateData.name !== 'string') {
130
+ errors.push({ field: 'name', message: 'Name must be a string' });
131
+ } else if (updateData.name.length > MAX_NAME_LENGTH) {
132
+ errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
133
+ }
134
+ }
135
+
136
+ // Note is optional but must be a string with valid length if provided
137
+ // Sanitize HTML to prevent XSS attacks
138
+ if (updateData.note !== undefined) {
139
+ if (typeof updateData.note !== 'string') {
140
+ errors.push({ field: 'note', message: 'Note must be a string' });
141
+ } else {
142
+ if (updateData.note.length > MAX_NOTE_LENGTH) {
143
+ errors.push({
144
+ field: 'note',
145
+ message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`,
146
+ });
147
+ }
148
+ // Sanitize HTML content - strip all HTML tags for notes
149
+ updateData.note = sanitizeHtml(updateData.note, {
150
+ allowedTags: [], // Strip all HTML
151
+ allowedAttributes: {},
152
+ });
153
+ }
154
+ }
155
+
156
+ // Labels is optional but must be an array of valid strings if provided
157
+ if (updateData.labels !== undefined) {
158
+ if (!Array.isArray(updateData.labels)) {
159
+ errors.push({ field: 'labels', message: 'Labels must be an array' });
160
+ } else {
161
+ // Validate each label is a non-empty string within length limit
162
+ const invalidLabels = updateData.labels.filter(
163
+ (label) =>
164
+ typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH
165
+ );
166
+ if (invalidLabels.length > 0) {
167
+ errors.push({
168
+ field: 'labels',
169
+ message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`,
170
+ });
171
+ }
172
+ }
173
+ }
174
+
175
+ // Newsletters is optional but must be an array of objects with valid id field
176
+ if (updateData.newsletters !== undefined) {
177
+ if (!Array.isArray(updateData.newsletters)) {
178
+ errors.push({ field: 'newsletters', message: 'Newsletters must be an array' });
179
+ } else {
180
+ // Validate each newsletter has a non-empty string id
181
+ const invalidNewsletters = updateData.newsletters.filter(
182
+ (newsletter) =>
183
+ !newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0
184
+ );
185
+ if (invalidNewsletters.length > 0) {
186
+ errors.push({
187
+ field: 'newsletters',
188
+ message: 'Each newsletter must have a non-empty id field',
189
+ });
190
+ }
191
+ }
192
+ }
193
+
194
+ if (errors.length > 0) {
195
+ throw new ValidationError('Member validation failed', errors);
196
+ }
197
+ }
198
+
199
+ export default {
200
+ validateMemberData,
201
+ validateMemberUpdateData,
202
+ };