@jgardner04/ghost-mcp-server 1.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
@@ -903,6 +903,122 @@ server.tool(
903
903
  }
904
904
  );
905
905
 
906
+ // =============================================================================
907
+ // MEMBER TOOLS
908
+ // Member management for Ghost CMS subscribers
909
+ // =============================================================================
910
+
911
+ // Create Member Tool
912
+ server.tool(
913
+ 'ghost_create_member',
914
+ 'Creates a new member (subscriber) in Ghost CMS.',
915
+ {
916
+ email: z.string().email().describe('The email address of the member (required).'),
917
+ name: z.string().optional().describe('The name of the member.'),
918
+ note: z.string().optional().describe('A note about the member.'),
919
+ labels: z.array(z.string()).optional().describe('List of label names to assign to the member.'),
920
+ newsletters: z
921
+ .array(z.object({ id: z.string() }))
922
+ .optional()
923
+ .describe('List of newsletter objects with id field to subscribe the member to.'),
924
+ subscribed: z
925
+ .boolean()
926
+ .optional()
927
+ .describe('Whether the member is subscribed to emails. Defaults to true.'),
928
+ },
929
+ async (input) => {
930
+ console.error(`Executing tool: ghost_create_member with email: ${input.email}`);
931
+ try {
932
+ await loadServices();
933
+
934
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
935
+ const createdMember = await ghostServiceImproved.createMember(input);
936
+ console.error(`Member created successfully. Member ID: ${createdMember.id}`);
937
+
938
+ return {
939
+ content: [{ type: 'text', text: JSON.stringify(createdMember, null, 2) }],
940
+ };
941
+ } catch (error) {
942
+ console.error(`Error in ghost_create_member:`, error);
943
+ return {
944
+ content: [{ type: 'text', text: `Error creating member: ${error.message}` }],
945
+ isError: true,
946
+ };
947
+ }
948
+ }
949
+ );
950
+
951
+ // Update Member Tool
952
+ server.tool(
953
+ 'ghost_update_member',
954
+ 'Updates an existing member in Ghost CMS. All fields except id are optional.',
955
+ {
956
+ id: z.string().describe('The ID of the member to update.'),
957
+ email: z.string().email().optional().describe('New email address for the member.'),
958
+ name: z.string().optional().describe('New name for the member.'),
959
+ note: z.string().optional().describe('New note about the member.'),
960
+ labels: z
961
+ .array(z.string())
962
+ .optional()
963
+ .describe('New list of label names to assign to the member.'),
964
+ newsletters: z
965
+ .array(z.object({ id: z.string() }))
966
+ .optional()
967
+ .describe('New list of newsletter objects with id field to subscribe the member to.'),
968
+ },
969
+ async (input) => {
970
+ console.error(`Executing tool: ghost_update_member for member ID: ${input.id}`);
971
+ try {
972
+ await loadServices();
973
+
974
+ const { id, ...updateData } = input;
975
+
976
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
977
+ const updatedMember = await ghostServiceImproved.updateMember(id, updateData);
978
+ console.error(`Member updated successfully. Member ID: ${updatedMember.id}`);
979
+
980
+ return {
981
+ content: [{ type: 'text', text: JSON.stringify(updatedMember, null, 2) }],
982
+ };
983
+ } catch (error) {
984
+ console.error(`Error in ghost_update_member:`, error);
985
+ return {
986
+ content: [{ type: 'text', text: `Error updating member: ${error.message}` }],
987
+ isError: true,
988
+ };
989
+ }
990
+ }
991
+ );
992
+
993
+ // Delete Member Tool
994
+ server.tool(
995
+ 'ghost_delete_member',
996
+ 'Deletes a member from Ghost CMS by ID. This operation is permanent and cannot be undone.',
997
+ {
998
+ id: z.string().describe('The ID of the member to delete.'),
999
+ },
1000
+ async ({ id }) => {
1001
+ console.error(`Executing tool: ghost_delete_member for member ID: ${id}`);
1002
+ try {
1003
+ await loadServices();
1004
+
1005
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
1006
+ await ghostServiceImproved.deleteMember(id);
1007
+ console.error(`Member deleted successfully. Member ID: ${id}`);
1008
+
1009
+ return {
1010
+ content: [{ type: 'text', text: `Member ${id} has been successfully deleted.` }],
1011
+ };
1012
+ } catch (error) {
1013
+ console.error(`Error in ghost_delete_member:`, error);
1014
+ return {
1015
+ content: [{ type: 'text', text: `Error deleting member: ${error.message}` }],
1016
+ isError: true,
1017
+ };
1018
+ }
1019
+ }
1020
+ );
1021
+
906
1022
  // =============================================================================
907
1023
  // NEWSLETTER TOOLS
908
1024
  // =============================================================================
@@ -1108,6 +1224,7 @@ async function main() {
1108
1224
  'Available tools: ghost_get_tags, ghost_create_tag, ghost_get_tag, ghost_update_tag, ghost_delete_tag, ghost_upload_image, ' +
1109
1225
  'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
1110
1226
  'ghost_get_pages, ghost_get_page, ghost_create_page, ghost_update_page, ghost_delete_page, ghost_search_pages, ' +
1227
+ 'ghost_create_member, ghost_update_member, ghost_delete_member, ' +
1111
1228
  'ghost_get_newsletters, ghost_get_newsletter, ghost_create_newsletter, ghost_update_newsletter, ghost_delete_newsletter'
1112
1229
  );
1113
1230
  }
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+
5
+ // Mock the Ghost Admin API with members support
6
+ vi.mock('@tryghost/admin-api', () => ({
7
+ default: vi.fn(function () {
8
+ return {
9
+ posts: {
10
+ add: vi.fn(),
11
+ browse: vi.fn(),
12
+ read: vi.fn(),
13
+ edit: vi.fn(),
14
+ delete: vi.fn(),
15
+ },
16
+ pages: {
17
+ add: vi.fn(),
18
+ browse: vi.fn(),
19
+ read: vi.fn(),
20
+ edit: vi.fn(),
21
+ delete: vi.fn(),
22
+ },
23
+ tags: {
24
+ add: vi.fn(),
25
+ browse: vi.fn(),
26
+ read: vi.fn(),
27
+ edit: vi.fn(),
28
+ delete: vi.fn(),
29
+ },
30
+ members: {
31
+ add: vi.fn(),
32
+ browse: vi.fn(),
33
+ read: vi.fn(),
34
+ edit: vi.fn(),
35
+ delete: vi.fn(),
36
+ },
37
+ site: {
38
+ read: vi.fn(),
39
+ },
40
+ images: {
41
+ upload: vi.fn(),
42
+ },
43
+ };
44
+ }),
45
+ }));
46
+
47
+ // Mock dotenv
48
+ vi.mock('dotenv', () => mockDotenv());
49
+
50
+ // Mock logger
51
+ vi.mock('../../utils/logger.js', () => ({
52
+ createContextLogger: createMockContextLogger(),
53
+ }));
54
+
55
+ // Mock fs for validateImagePath
56
+ vi.mock('fs/promises', () => ({
57
+ default: {
58
+ access: vi.fn(),
59
+ },
60
+ }));
61
+
62
+ // Import after setting up mocks
63
+ import { createMember, updateMember, deleteMember, api } from '../ghostServiceImproved.js';
64
+
65
+ describe('ghostServiceImproved - Members', () => {
66
+ beforeEach(() => {
67
+ // Reset all mocks before each test
68
+ vi.clearAllMocks();
69
+ });
70
+
71
+ describe('createMember', () => {
72
+ it('should create a member with required email', async () => {
73
+ const memberData = {
74
+ email: 'test@example.com',
75
+ };
76
+
77
+ const mockCreatedMember = {
78
+ id: 'member-1',
79
+ email: 'test@example.com',
80
+ status: 'free',
81
+ };
82
+
83
+ api.members.add.mockResolvedValue(mockCreatedMember);
84
+
85
+ const result = await createMember(memberData);
86
+
87
+ expect(api.members.add).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ email: 'test@example.com',
90
+ }),
91
+ expect.any(Object)
92
+ );
93
+ expect(result).toEqual(mockCreatedMember);
94
+ });
95
+
96
+ it('should create a member with optional fields', async () => {
97
+ const memberData = {
98
+ email: 'test@example.com',
99
+ name: 'John Doe',
100
+ note: 'Test member',
101
+ labels: ['premium', 'newsletter'],
102
+ newsletters: [{ id: 'newsletter-1' }],
103
+ subscribed: true,
104
+ };
105
+
106
+ const mockCreatedMember = {
107
+ id: 'member-1',
108
+ ...memberData,
109
+ status: 'free',
110
+ };
111
+
112
+ api.members.add.mockResolvedValue(mockCreatedMember);
113
+
114
+ const result = await createMember(memberData);
115
+
116
+ expect(api.members.add).toHaveBeenCalledWith(
117
+ expect.objectContaining(memberData),
118
+ expect.any(Object)
119
+ );
120
+ expect(result).toEqual(mockCreatedMember);
121
+ });
122
+
123
+ it('should throw validation error for missing email', async () => {
124
+ await expect(createMember({})).rejects.toThrow('Member validation failed');
125
+ });
126
+
127
+ it('should throw validation error for invalid email', async () => {
128
+ await expect(createMember({ email: 'invalid-email' })).rejects.toThrow(
129
+ 'Member validation failed'
130
+ );
131
+ });
132
+
133
+ it('should throw validation error for invalid labels type', async () => {
134
+ await expect(
135
+ createMember({
136
+ email: 'test@example.com',
137
+ labels: 'premium',
138
+ })
139
+ ).rejects.toThrow('Member validation failed');
140
+ });
141
+
142
+ it('should handle Ghost API errors', async () => {
143
+ const memberData = {
144
+ email: 'test@example.com',
145
+ };
146
+
147
+ api.members.add.mockRejectedValue(new Error('Ghost API Error'));
148
+
149
+ await expect(createMember(memberData)).rejects.toThrow();
150
+ });
151
+ });
152
+
153
+ describe('updateMember', () => {
154
+ it('should update a member with valid ID and data', async () => {
155
+ const memberId = 'member-1';
156
+ const updateData = {
157
+ name: 'Jane Doe',
158
+ note: 'Updated note',
159
+ };
160
+
161
+ const mockExistingMember = {
162
+ id: memberId,
163
+ email: 'test@example.com',
164
+ name: 'John Doe',
165
+ updated_at: '2023-01-01T00:00:00.000Z',
166
+ };
167
+
168
+ const mockUpdatedMember = {
169
+ ...mockExistingMember,
170
+ ...updateData,
171
+ };
172
+
173
+ api.members.read.mockResolvedValue(mockExistingMember);
174
+ api.members.edit.mockResolvedValue(mockUpdatedMember);
175
+
176
+ const result = await updateMember(memberId, updateData);
177
+
178
+ expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: memberId });
179
+ expect(api.members.edit).toHaveBeenCalledWith(
180
+ expect.objectContaining({
181
+ ...mockExistingMember,
182
+ ...updateData,
183
+ }),
184
+ expect.objectContaining({ id: memberId })
185
+ );
186
+ expect(result).toEqual(mockUpdatedMember);
187
+ });
188
+
189
+ it('should update member email if provided', async () => {
190
+ const memberId = 'member-1';
191
+ const updateData = {
192
+ email: 'newemail@example.com',
193
+ };
194
+
195
+ const mockExistingMember = {
196
+ id: memberId,
197
+ email: 'test@example.com',
198
+ updated_at: '2023-01-01T00:00:00.000Z',
199
+ };
200
+
201
+ const mockUpdatedMember = {
202
+ ...mockExistingMember,
203
+ email: 'newemail@example.com',
204
+ };
205
+
206
+ api.members.read.mockResolvedValue(mockExistingMember);
207
+ api.members.edit.mockResolvedValue(mockUpdatedMember);
208
+
209
+ const result = await updateMember(memberId, updateData);
210
+
211
+ expect(result.email).toBe('newemail@example.com');
212
+ });
213
+
214
+ it('should throw validation error for missing member ID', async () => {
215
+ await expect(updateMember(null, { name: 'Test' })).rejects.toThrow(
216
+ 'Member ID is required for update'
217
+ );
218
+ });
219
+
220
+ it('should throw validation error for invalid email in update', async () => {
221
+ await expect(updateMember('member-1', { email: 'invalid-email' })).rejects.toThrow(
222
+ 'Member validation failed'
223
+ );
224
+ });
225
+
226
+ it('should throw not found error if member does not exist', async () => {
227
+ api.members.read.mockRejectedValue({
228
+ response: { status: 404 },
229
+ message: 'Member not found',
230
+ });
231
+
232
+ await expect(updateMember('non-existent', { name: 'Test' })).rejects.toThrow();
233
+ });
234
+ });
235
+
236
+ describe('deleteMember', () => {
237
+ it('should delete a member with valid ID', async () => {
238
+ const memberId = 'member-1';
239
+
240
+ api.members.delete.mockResolvedValue({ deleted: true });
241
+
242
+ const result = await deleteMember(memberId);
243
+
244
+ expect(api.members.delete).toHaveBeenCalledWith(memberId, expect.any(Object));
245
+ expect(result).toEqual({ deleted: true });
246
+ });
247
+
248
+ it('should throw validation error for missing member ID', async () => {
249
+ await expect(deleteMember(null)).rejects.toThrow('Member ID is required for deletion');
250
+ });
251
+
252
+ it('should throw not found error if member does not exist', async () => {
253
+ api.members.delete.mockRejectedValue({
254
+ response: { status: 404 },
255
+ message: 'Member not found',
256
+ });
257
+
258
+ await expect(deleteMember('non-existent')).rejects.toThrow();
259
+ });
260
+ });
261
+ });
@@ -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
+ };