@jgardner04/ghost-mcp-server 1.9.0 → 1.10.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.9.0",
3
+ "version": "1.10.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",
@@ -1019,6 +1019,125 @@ server.tool(
1019
1019
  }
1020
1020
  );
1021
1021
 
1022
+ // Get Members Tool
1023
+ server.tool(
1024
+ 'ghost_get_members',
1025
+ 'Retrieves a list of members (subscribers) from Ghost CMS with optional filtering, pagination, and includes.',
1026
+ {
1027
+ limit: z
1028
+ .number()
1029
+ .min(1)
1030
+ .max(100)
1031
+ .optional()
1032
+ .describe('Number of members to retrieve (1-100). Default is 15.'),
1033
+ page: z.number().min(1).optional().describe('Page number for pagination (starts at 1).'),
1034
+ filter: z
1035
+ .string()
1036
+ .optional()
1037
+ .describe('Ghost NQL filter string (e.g., "status:free", "status:paid", "subscribed:true").'),
1038
+ order: z.string().optional().describe('Order string (e.g., "created_at desc", "email asc").'),
1039
+ include: z
1040
+ .string()
1041
+ .optional()
1042
+ .describe('Comma-separated list of related data to include (e.g., "labels,newsletters").'),
1043
+ },
1044
+ async (input) => {
1045
+ console.error(`Executing tool: ghost_get_members`);
1046
+ try {
1047
+ await loadServices();
1048
+
1049
+ const options = {};
1050
+ if (input.limit !== undefined) options.limit = input.limit;
1051
+ if (input.page !== undefined) options.page = input.page;
1052
+ if (input.filter !== undefined) options.filter = input.filter;
1053
+ if (input.order !== undefined) options.order = input.order;
1054
+ if (input.include !== undefined) options.include = input.include;
1055
+
1056
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
1057
+ const members = await ghostServiceImproved.getMembers(options);
1058
+ console.error(`Retrieved ${members.length} members from Ghost.`);
1059
+
1060
+ return {
1061
+ content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
1062
+ };
1063
+ } catch (error) {
1064
+ console.error(`Error in ghost_get_members:`, error);
1065
+ return {
1066
+ content: [{ type: 'text', text: `Error retrieving members: ${error.message}` }],
1067
+ isError: true,
1068
+ };
1069
+ }
1070
+ }
1071
+ );
1072
+
1073
+ // Get Member Tool
1074
+ server.tool(
1075
+ 'ghost_get_member',
1076
+ 'Retrieves a single member from Ghost CMS by ID or email. Provide either id OR email.',
1077
+ {
1078
+ id: z.string().optional().describe('The ID of the member to retrieve.'),
1079
+ email: z.string().email().optional().describe('The email of the member to retrieve.'),
1080
+ },
1081
+ async ({ id, email }) => {
1082
+ console.error(`Executing tool: ghost_get_member for ${id ? `ID: ${id}` : `email: ${email}`}`);
1083
+ try {
1084
+ await loadServices();
1085
+
1086
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
1087
+ const member = await ghostServiceImproved.getMember({ id, email });
1088
+ console.error(`Retrieved member: ${member.email} (ID: ${member.id})`);
1089
+
1090
+ return {
1091
+ content: [{ type: 'text', text: JSON.stringify(member, null, 2) }],
1092
+ };
1093
+ } catch (error) {
1094
+ console.error(`Error in ghost_get_member:`, error);
1095
+ return {
1096
+ content: [{ type: 'text', text: `Error retrieving member: ${error.message}` }],
1097
+ isError: true,
1098
+ };
1099
+ }
1100
+ }
1101
+ );
1102
+
1103
+ // Search Members Tool
1104
+ server.tool(
1105
+ 'ghost_search_members',
1106
+ 'Searches for members by name or email in Ghost CMS.',
1107
+ {
1108
+ query: z.string().min(1).describe('Search query to match against member name or email.'),
1109
+ limit: z
1110
+ .number()
1111
+ .min(1)
1112
+ .max(50)
1113
+ .optional()
1114
+ .describe('Maximum number of results to return (1-50). Default is 15.'),
1115
+ },
1116
+ async ({ query, limit }) => {
1117
+ console.error(`Executing tool: ghost_search_members with query: ${query}`);
1118
+ try {
1119
+ await loadServices();
1120
+
1121
+ const options = {};
1122
+ if (limit !== undefined) options.limit = limit;
1123
+
1124
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
1125
+ const members = await ghostServiceImproved.searchMembers(query, options);
1126
+ console.error(`Found ${members.length} members matching "${query}".`);
1127
+
1128
+ return {
1129
+ content: [{ type: 'text', text: JSON.stringify(members, null, 2) }],
1130
+ };
1131
+ } catch (error) {
1132
+ console.error(`Error in ghost_search_members:`, error);
1133
+ return {
1134
+ content: [{ type: 'text', text: `Error searching members: ${error.message}` }],
1135
+ isError: true,
1136
+ };
1137
+ }
1138
+ }
1139
+ );
1140
+
1022
1141
  // =============================================================================
1023
1142
  // NEWSLETTER TOOLS
1024
1143
  // =============================================================================
@@ -1224,7 +1343,7 @@ async function main() {
1224
1343
  'Available tools: ghost_get_tags, ghost_create_tag, ghost_get_tag, ghost_update_tag, ghost_delete_tag, ghost_upload_image, ' +
1225
1344
  'ghost_create_post, ghost_get_posts, ghost_get_post, ghost_search_posts, ghost_update_post, ghost_delete_post, ' +
1226
1345
  '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, ' +
1346
+ 'ghost_create_member, ghost_update_member, ghost_delete_member, ghost_get_members, ghost_get_member, ghost_search_members, ' +
1228
1347
  'ghost_get_newsletters, ghost_get_newsletter, ghost_create_newsletter, ghost_update_newsletter, ghost_delete_newsletter'
1229
1348
  );
1230
1349
  }
@@ -60,7 +60,15 @@ vi.mock('fs/promises', () => ({
60
60
  }));
61
61
 
62
62
  // Import after setting up mocks
63
- import { createMember, updateMember, deleteMember, api } from '../ghostServiceImproved.js';
63
+ import {
64
+ createMember,
65
+ updateMember,
66
+ deleteMember,
67
+ getMembers,
68
+ getMember,
69
+ searchMembers,
70
+ api,
71
+ } from '../ghostServiceImproved.js';
64
72
 
65
73
  describe('ghostServiceImproved - Members', () => {
66
74
  beforeEach(() => {
@@ -258,4 +266,283 @@ describe('ghostServiceImproved - Members', () => {
258
266
  await expect(deleteMember('non-existent')).rejects.toThrow();
259
267
  });
260
268
  });
269
+
270
+ describe('getMembers', () => {
271
+ it('should return all members with default options', async () => {
272
+ const mockMembers = [
273
+ { id: 'member-1', email: 'test1@example.com', status: 'free' },
274
+ { id: 'member-2', email: 'test2@example.com', status: 'paid' },
275
+ ];
276
+
277
+ api.members.browse.mockResolvedValue(mockMembers);
278
+
279
+ const result = await getMembers();
280
+
281
+ expect(api.members.browse).toHaveBeenCalled();
282
+ expect(result).toEqual(mockMembers);
283
+ });
284
+
285
+ it('should accept pagination options', async () => {
286
+ const mockMembers = [{ id: 'member-1', email: 'test1@example.com' }];
287
+
288
+ api.members.browse.mockResolvedValue(mockMembers);
289
+
290
+ await getMembers({ limit: 50, page: 2 });
291
+
292
+ expect(api.members.browse).toHaveBeenCalledWith(
293
+ expect.objectContaining({
294
+ limit: 50,
295
+ page: 2,
296
+ }),
297
+ expect.any(Object)
298
+ );
299
+ });
300
+
301
+ it('should accept filter options', async () => {
302
+ const mockMembers = [{ id: 'member-1', email: 'test1@example.com', status: 'paid' }];
303
+
304
+ api.members.browse.mockResolvedValue(mockMembers);
305
+
306
+ await getMembers({ filter: 'status:paid' });
307
+
308
+ expect(api.members.browse).toHaveBeenCalledWith(
309
+ expect.objectContaining({
310
+ filter: 'status:paid',
311
+ }),
312
+ expect.any(Object)
313
+ );
314
+ });
315
+
316
+ it('should accept order options', async () => {
317
+ const mockMembers = [{ id: 'member-1', email: 'test1@example.com' }];
318
+
319
+ api.members.browse.mockResolvedValue(mockMembers);
320
+
321
+ await getMembers({ order: 'created_at desc' });
322
+
323
+ expect(api.members.browse).toHaveBeenCalledWith(
324
+ expect.objectContaining({
325
+ order: 'created_at desc',
326
+ }),
327
+ expect.any(Object)
328
+ );
329
+ });
330
+
331
+ it('should accept include options', async () => {
332
+ const mockMembers = [
333
+ { id: 'member-1', email: 'test1@example.com', labels: [], newsletters: [] },
334
+ ];
335
+
336
+ api.members.browse.mockResolvedValue(mockMembers);
337
+
338
+ await getMembers({ include: 'labels,newsletters' });
339
+
340
+ expect(api.members.browse).toHaveBeenCalledWith(
341
+ expect.objectContaining({
342
+ include: 'labels,newsletters',
343
+ }),
344
+ expect.any(Object)
345
+ );
346
+ });
347
+
348
+ it('should throw validation error for invalid limit', async () => {
349
+ await expect(getMembers({ limit: 0 })).rejects.toThrow('Member query validation failed');
350
+ await expect(getMembers({ limit: 101 })).rejects.toThrow('Member query validation failed');
351
+ });
352
+
353
+ it('should throw validation error for invalid page', async () => {
354
+ await expect(getMembers({ page: 0 })).rejects.toThrow('Member query validation failed');
355
+ });
356
+
357
+ it('should return empty array when no members found', async () => {
358
+ api.members.browse.mockResolvedValue([]);
359
+
360
+ const result = await getMembers();
361
+
362
+ expect(result).toEqual([]);
363
+ });
364
+
365
+ it('should handle Ghost API errors', async () => {
366
+ api.members.browse.mockRejectedValue(new Error('Ghost API Error'));
367
+
368
+ await expect(getMembers()).rejects.toThrow();
369
+ });
370
+ });
371
+
372
+ describe('getMember', () => {
373
+ it('should get member by ID', async () => {
374
+ const mockMember = {
375
+ id: 'member-1',
376
+ email: 'test@example.com',
377
+ name: 'John Doe',
378
+ status: 'free',
379
+ };
380
+
381
+ api.members.read.mockResolvedValue(mockMember);
382
+
383
+ const result = await getMember({ id: 'member-1' });
384
+
385
+ expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: 'member-1' });
386
+ expect(result).toEqual(mockMember);
387
+ });
388
+
389
+ it('should get member by email', async () => {
390
+ const mockMember = {
391
+ id: 'member-1',
392
+ email: 'test@example.com',
393
+ name: 'John Doe',
394
+ status: 'free',
395
+ };
396
+
397
+ api.members.browse.mockResolvedValue([mockMember]);
398
+
399
+ const result = await getMember({ email: 'test@example.com' });
400
+
401
+ expect(api.members.browse).toHaveBeenCalledWith(
402
+ expect.objectContaining({
403
+ filter: expect.stringContaining('test@example.com'),
404
+ }),
405
+ expect.any(Object)
406
+ );
407
+ expect(result).toEqual(mockMember);
408
+ });
409
+
410
+ it('should throw validation error when neither id nor email provided', async () => {
411
+ await expect(getMember({})).rejects.toThrow('Member lookup validation failed');
412
+ });
413
+
414
+ it('should throw validation error for invalid email format', async () => {
415
+ await expect(getMember({ email: 'invalid-email' })).rejects.toThrow(
416
+ 'Member lookup validation failed'
417
+ );
418
+ });
419
+
420
+ it('should throw not found error when member not found by ID', async () => {
421
+ api.members.read.mockRejectedValue({
422
+ response: { status: 404 },
423
+ message: 'Member not found',
424
+ });
425
+
426
+ await expect(getMember({ id: 'non-existent' })).rejects.toThrow();
427
+ });
428
+
429
+ it('should throw not found error when member not found by email', async () => {
430
+ api.members.browse.mockResolvedValue([]);
431
+
432
+ await expect(getMember({ email: 'notfound@example.com' })).rejects.toThrow(
433
+ 'Member not found'
434
+ );
435
+ });
436
+
437
+ it('should prioritize ID over email when both provided', async () => {
438
+ const mockMember = {
439
+ id: 'member-1',
440
+ email: 'test@example.com',
441
+ status: 'free',
442
+ };
443
+
444
+ api.members.read.mockResolvedValue(mockMember);
445
+
446
+ await getMember({ id: 'member-1', email: 'test@example.com' });
447
+
448
+ // Should use read (ID) instead of browse (email)
449
+ expect(api.members.read).toHaveBeenCalled();
450
+ expect(api.members.browse).not.toHaveBeenCalled();
451
+ });
452
+ });
453
+
454
+ describe('searchMembers', () => {
455
+ it('should search members by query', async () => {
456
+ const mockMembers = [{ id: 'member-1', email: 'john@example.com', name: 'John Doe' }];
457
+
458
+ api.members.browse.mockResolvedValue(mockMembers);
459
+
460
+ const result = await searchMembers('john');
461
+
462
+ expect(api.members.browse).toHaveBeenCalled();
463
+ expect(result).toEqual(mockMembers);
464
+ });
465
+
466
+ it('should apply default limit of 15', async () => {
467
+ const mockMembers = [];
468
+
469
+ api.members.browse.mockResolvedValue(mockMembers);
470
+
471
+ await searchMembers('test');
472
+
473
+ expect(api.members.browse).toHaveBeenCalledWith(
474
+ expect.objectContaining({
475
+ limit: 15,
476
+ }),
477
+ expect.any(Object)
478
+ );
479
+ });
480
+
481
+ it('should accept custom limit', async () => {
482
+ const mockMembers = [];
483
+
484
+ api.members.browse.mockResolvedValue(mockMembers);
485
+
486
+ await searchMembers('test', { limit: 25 });
487
+
488
+ expect(api.members.browse).toHaveBeenCalledWith(
489
+ expect.objectContaining({
490
+ limit: 25,
491
+ }),
492
+ expect.any(Object)
493
+ );
494
+ });
495
+
496
+ it('should throw validation error for empty query', async () => {
497
+ await expect(searchMembers('')).rejects.toThrow('Search query validation failed');
498
+ await expect(searchMembers(' ')).rejects.toThrow('Search query validation failed');
499
+ });
500
+
501
+ it('should throw validation error for non-string query', async () => {
502
+ await expect(searchMembers(123)).rejects.toThrow('Search query validation failed');
503
+ await expect(searchMembers(null)).rejects.toThrow('Search query validation failed');
504
+ });
505
+
506
+ it('should throw validation error for invalid limit', async () => {
507
+ await expect(searchMembers('test', { limit: 0 })).rejects.toThrow(
508
+ 'Search options validation failed'
509
+ );
510
+ await expect(searchMembers('test', { limit: 51 })).rejects.toThrow(
511
+ 'Search options validation failed'
512
+ );
513
+ await expect(searchMembers('test', { limit: 100 })).rejects.toThrow(
514
+ 'Search options validation failed'
515
+ );
516
+ });
517
+
518
+ it('should sanitize query to prevent NQL injection', async () => {
519
+ const mockMembers = [];
520
+
521
+ api.members.browse.mockResolvedValue(mockMembers);
522
+
523
+ // Query with special NQL characters
524
+ await searchMembers("test'value");
525
+
526
+ expect(api.members.browse).toHaveBeenCalledWith(
527
+ expect.objectContaining({
528
+ filter: expect.stringContaining("\\'"),
529
+ }),
530
+ expect.any(Object)
531
+ );
532
+ });
533
+
534
+ it('should return empty array when no matches found', async () => {
535
+ api.members.browse.mockResolvedValue([]);
536
+
537
+ const result = await searchMembers('nonexistent');
538
+
539
+ expect(result).toEqual([]);
540
+ });
541
+
542
+ it('should handle Ghost API errors', async () => {
543
+ api.members.browse.mockRejectedValue(new Error('Ghost API Error'));
544
+
545
+ await expect(searchMembers('test')).rejects.toThrow();
546
+ });
547
+ });
261
548
  });
@@ -1,5 +1,13 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { validateMemberData, validateMemberUpdateData } from '../memberService.js';
2
+ import {
3
+ validateMemberData,
4
+ validateMemberUpdateData,
5
+ validateMemberQueryOptions,
6
+ validateMemberLookup,
7
+ validateSearchQuery,
8
+ validateSearchOptions,
9
+ sanitizeNqlValue,
10
+ } from '../memberService.js';
3
11
 
4
12
  describe('memberService - Validation', () => {
5
13
  describe('validateMemberData', () => {
@@ -242,4 +250,228 @@ describe('memberService - Validation', () => {
242
250
  );
243
251
  });
244
252
  });
253
+
254
+ describe('validateMemberQueryOptions', () => {
255
+ it('should accept empty options', () => {
256
+ expect(() => validateMemberQueryOptions({})).not.toThrow();
257
+ });
258
+
259
+ it('should accept valid limit within bounds', () => {
260
+ expect(() => validateMemberQueryOptions({ limit: 1 })).not.toThrow();
261
+ expect(() => validateMemberQueryOptions({ limit: 50 })).not.toThrow();
262
+ expect(() => validateMemberQueryOptions({ limit: 100 })).not.toThrow();
263
+ });
264
+
265
+ it('should reject limit below minimum', () => {
266
+ expect(() => validateMemberQueryOptions({ limit: 0 })).toThrow(
267
+ 'Member query validation failed'
268
+ );
269
+ expect(() => validateMemberQueryOptions({ limit: -1 })).toThrow(
270
+ 'Member query validation failed'
271
+ );
272
+ });
273
+
274
+ it('should reject limit above maximum', () => {
275
+ expect(() => validateMemberQueryOptions({ limit: 101 })).toThrow(
276
+ 'Member query validation failed'
277
+ );
278
+ });
279
+
280
+ it('should accept valid page number', () => {
281
+ expect(() => validateMemberQueryOptions({ page: 1 })).not.toThrow();
282
+ expect(() => validateMemberQueryOptions({ page: 100 })).not.toThrow();
283
+ });
284
+
285
+ it('should reject page below minimum', () => {
286
+ expect(() => validateMemberQueryOptions({ page: 0 })).toThrow(
287
+ 'Member query validation failed'
288
+ );
289
+ expect(() => validateMemberQueryOptions({ page: -1 })).toThrow(
290
+ 'Member query validation failed'
291
+ );
292
+ });
293
+
294
+ it('should accept valid filter strings', () => {
295
+ expect(() => validateMemberQueryOptions({ filter: 'status:free' })).not.toThrow();
296
+ expect(() => validateMemberQueryOptions({ filter: 'status:paid' })).not.toThrow();
297
+ expect(() => validateMemberQueryOptions({ filter: 'subscribed:true' })).not.toThrow();
298
+ });
299
+
300
+ it('should reject empty filter string', () => {
301
+ expect(() => validateMemberQueryOptions({ filter: '' })).toThrow(
302
+ 'Member query validation failed'
303
+ );
304
+ expect(() => validateMemberQueryOptions({ filter: ' ' })).toThrow(
305
+ 'Member query validation failed'
306
+ );
307
+ });
308
+
309
+ it('should accept valid order strings', () => {
310
+ expect(() => validateMemberQueryOptions({ order: 'created_at desc' })).not.toThrow();
311
+ expect(() => validateMemberQueryOptions({ order: 'email asc' })).not.toThrow();
312
+ });
313
+
314
+ it('should reject empty order string', () => {
315
+ expect(() => validateMemberQueryOptions({ order: '' })).toThrow(
316
+ 'Member query validation failed'
317
+ );
318
+ });
319
+
320
+ it('should accept valid include strings', () => {
321
+ expect(() => validateMemberQueryOptions({ include: 'labels' })).not.toThrow();
322
+ expect(() => validateMemberQueryOptions({ include: 'newsletters' })).not.toThrow();
323
+ expect(() => validateMemberQueryOptions({ include: 'labels,newsletters' })).not.toThrow();
324
+ });
325
+
326
+ it('should reject empty include string', () => {
327
+ expect(() => validateMemberQueryOptions({ include: '' })).toThrow(
328
+ 'Member query validation failed'
329
+ );
330
+ });
331
+
332
+ it('should validate multiple options together', () => {
333
+ expect(() =>
334
+ validateMemberQueryOptions({
335
+ limit: 50,
336
+ page: 2,
337
+ filter: 'status:paid',
338
+ order: 'created_at desc',
339
+ include: 'labels,newsletters',
340
+ })
341
+ ).not.toThrow();
342
+ });
343
+ });
344
+
345
+ describe('validateMemberLookup', () => {
346
+ it('should accept valid id', () => {
347
+ expect(() => validateMemberLookup({ id: '12345' })).not.toThrow();
348
+ });
349
+
350
+ it('should accept valid email', () => {
351
+ expect(() => validateMemberLookup({ email: 'test@example.com' })).not.toThrow();
352
+ });
353
+
354
+ it('should reject when both id and email are missing', () => {
355
+ expect(() => validateMemberLookup({})).toThrow('Member lookup validation failed');
356
+ });
357
+
358
+ it('should reject empty id', () => {
359
+ expect(() => validateMemberLookup({ id: '' })).toThrow('Member lookup validation failed');
360
+ expect(() => validateMemberLookup({ id: ' ' })).toThrow('Member lookup validation failed');
361
+ });
362
+
363
+ it('should reject invalid email format', () => {
364
+ expect(() => validateMemberLookup({ email: 'invalid-email' })).toThrow(
365
+ 'Member lookup validation failed'
366
+ );
367
+ expect(() => validateMemberLookup({ email: 'test@' })).toThrow(
368
+ 'Member lookup validation failed'
369
+ );
370
+ });
371
+
372
+ it('should accept when both id and email provided (id takes precedence)', () => {
373
+ expect(() => validateMemberLookup({ id: '12345', email: 'test@example.com' })).not.toThrow();
374
+ });
375
+
376
+ it('should return normalized params with lookupType', () => {
377
+ const resultId = validateMemberLookup({ id: '12345' });
378
+ expect(resultId).toEqual({ id: '12345', lookupType: 'id' });
379
+
380
+ const resultEmail = validateMemberLookup({ email: 'test@example.com' });
381
+ expect(resultEmail).toEqual({ email: 'test@example.com', lookupType: 'email' });
382
+
383
+ // ID takes precedence when both provided
384
+ const resultBoth = validateMemberLookup({ id: '12345', email: 'test@example.com' });
385
+ expect(resultBoth).toEqual({ id: '12345', lookupType: 'id' });
386
+ });
387
+ });
388
+
389
+ describe('validateSearchQuery', () => {
390
+ it('should accept valid search query', () => {
391
+ expect(() => validateSearchQuery('john')).not.toThrow();
392
+ expect(() => validateSearchQuery('john@example.com')).not.toThrow();
393
+ });
394
+
395
+ it('should reject empty search query', () => {
396
+ expect(() => validateSearchQuery('')).toThrow('Search query validation failed');
397
+ expect(() => validateSearchQuery(' ')).toThrow('Search query validation failed');
398
+ });
399
+
400
+ it('should reject non-string search query', () => {
401
+ expect(() => validateSearchQuery(123)).toThrow('Search query validation failed');
402
+ expect(() => validateSearchQuery(null)).toThrow('Search query validation failed');
403
+ expect(() => validateSearchQuery(undefined)).toThrow('Search query validation failed');
404
+ });
405
+
406
+ it('should return sanitized query', () => {
407
+ const result = validateSearchQuery('john');
408
+ expect(result).toBe('john');
409
+ });
410
+
411
+ it('should trim whitespace from query', () => {
412
+ const result = validateSearchQuery(' john ');
413
+ expect(result).toBe('john');
414
+ });
415
+ });
416
+
417
+ describe('validateSearchOptions', () => {
418
+ it('should accept empty options', () => {
419
+ expect(() => validateSearchOptions({})).not.toThrow();
420
+ });
421
+
422
+ it('should accept valid limit within bounds (1-50)', () => {
423
+ expect(() => validateSearchOptions({ limit: 1 })).not.toThrow();
424
+ expect(() => validateSearchOptions({ limit: 25 })).not.toThrow();
425
+ expect(() => validateSearchOptions({ limit: 50 })).not.toThrow();
426
+ });
427
+
428
+ it('should reject limit below minimum', () => {
429
+ expect(() => validateSearchOptions({ limit: 0 })).toThrow('Search options validation failed');
430
+ expect(() => validateSearchOptions({ limit: -1 })).toThrow(
431
+ 'Search options validation failed'
432
+ );
433
+ });
434
+
435
+ it('should reject limit above maximum (50)', () => {
436
+ expect(() => validateSearchOptions({ limit: 51 })).toThrow(
437
+ 'Search options validation failed'
438
+ );
439
+ expect(() => validateSearchOptions({ limit: 100 })).toThrow(
440
+ 'Search options validation failed'
441
+ );
442
+ });
443
+
444
+ it('should reject non-number limit', () => {
445
+ expect(() => validateSearchOptions({ limit: 'ten' })).toThrow(
446
+ 'Search options validation failed'
447
+ );
448
+ });
449
+ });
450
+
451
+ describe('sanitizeNqlValue', () => {
452
+ it('should escape backslashes', () => {
453
+ expect(sanitizeNqlValue('test\\value')).toBe('test\\\\value');
454
+ });
455
+
456
+ it('should escape single quotes', () => {
457
+ expect(sanitizeNqlValue("test'value")).toBe("test\\'value");
458
+ });
459
+
460
+ it('should escape double quotes', () => {
461
+ expect(sanitizeNqlValue('test"value')).toBe('test\\"value');
462
+ });
463
+
464
+ it('should handle multiple special characters', () => {
465
+ expect(sanitizeNqlValue('test\'value"with\\chars')).toBe('test\\\'value\\"with\\\\chars');
466
+ });
467
+
468
+ it('should not modify strings without special characters', () => {
469
+ expect(sanitizeNqlValue('normalvalue')).toBe('normalvalue');
470
+ expect(sanitizeNqlValue('test@example.com')).toBe('test@example.com');
471
+ });
472
+
473
+ it('should handle empty string', () => {
474
+ expect(sanitizeNqlValue('')).toBe('');
475
+ });
476
+ });
245
477
  });
@@ -876,6 +876,111 @@ export async function deleteMember(memberId) {
876
876
  }
877
877
  }
878
878
 
879
+ /**
880
+ * List members from Ghost CMS with optional filtering and pagination
881
+ * @param {Object} [options] - Query options
882
+ * @param {number} [options.limit] - Number of members to return (1-100)
883
+ * @param {number} [options.page] - Page number (1+)
884
+ * @param {string} [options.filter] - NQL filter string (e.g., 'status:paid')
885
+ * @param {string} [options.order] - Order string (e.g., 'created_at desc')
886
+ * @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
887
+ * @returns {Promise<Array>} Array of member objects
888
+ * @throws {ValidationError} If validation fails
889
+ * @throws {GhostAPIError} If the API request fails
890
+ */
891
+ export async function getMembers(options = {}) {
892
+ // Import and validate query options
893
+ const { validateMemberQueryOptions } = await import('./memberService.js');
894
+ validateMemberQueryOptions(options);
895
+
896
+ const defaultOptions = {
897
+ limit: 15,
898
+ ...options,
899
+ };
900
+
901
+ try {
902
+ const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
903
+ return members || [];
904
+ } catch (error) {
905
+ console.error('Failed to get members:', error);
906
+ throw error;
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Get a single member from Ghost CMS by ID or email
912
+ * @param {Object} params - Lookup parameters (id OR email required)
913
+ * @param {string} [params.id] - Member ID
914
+ * @param {string} [params.email] - Member email
915
+ * @returns {Promise<Object>} The member object
916
+ * @throws {ValidationError} If validation fails
917
+ * @throws {NotFoundError} If the member is not found
918
+ * @throws {GhostAPIError} If the API request fails
919
+ */
920
+ 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);
924
+
925
+ try {
926
+ if (lookupType === 'id') {
927
+ // Lookup by ID using read endpoint
928
+ return await handleApiRequest('members', 'read', { id }, { id });
929
+ } else {
930
+ // Lookup by email using browse with filter
931
+ const sanitizedEmail = sanitizeNqlValue(email);
932
+ const members = await handleApiRequest(
933
+ 'members',
934
+ 'browse',
935
+ {},
936
+ { filter: `email:'${sanitizedEmail}'`, limit: 1 }
937
+ );
938
+
939
+ if (!members || members.length === 0) {
940
+ throw new NotFoundError('Member', email);
941
+ }
942
+
943
+ return members[0];
944
+ }
945
+ } catch (error) {
946
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
947
+ throw new NotFoundError('Member', id || email);
948
+ }
949
+ throw error;
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Search members by name or email
955
+ * @param {string} query - Search query (searches name and email fields)
956
+ * @param {Object} [options] - Additional options
957
+ * @param {number} [options.limit] - Maximum number of results (default: 15)
958
+ * @returns {Promise<Array>} Array of matching member objects
959
+ * @throws {ValidationError} If validation fails
960
+ * @throws {GhostAPIError} If the API request fails
961
+ */
962
+ 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));
968
+
969
+ const limit = options.limit || 15;
970
+
971
+ // Build NQL filter for name or email containing the query
972
+ // Ghost uses ~ for contains/like matching
973
+ const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
974
+
975
+ try {
976
+ const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
977
+ return members || [];
978
+ } catch (error) {
979
+ console.error('Failed to search members:', error);
980
+ throw error;
981
+ }
982
+ }
983
+
879
984
  /**
880
985
  * Newsletter CRUD Operations
881
986
  */
@@ -1027,6 +1132,9 @@ export default {
1027
1132
  createMember,
1028
1133
  updateMember,
1029
1134
  deleteMember,
1135
+ getMembers,
1136
+ getMember,
1137
+ searchMembers,
1030
1138
  getNewsletters,
1031
1139
  getNewsletter,
1032
1140
  createNewsletter,
@@ -14,6 +14,14 @@ const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit
14
14
  const MAX_NOTE_LENGTH = 2000; // Reasonable limit for notes
15
15
  const MAX_LABEL_LENGTH = 191; // Label name limit
16
16
 
17
+ /**
18
+ * Query constraints for member browsing
19
+ */
20
+ const MIN_LIMIT = 1;
21
+ const MAX_LIMIT = 100;
22
+ const MAX_SEARCH_LIMIT = 50; // Lower limit for search operations
23
+ const MIN_PAGE = 1;
24
+
17
25
  /**
18
26
  * Validates member data for creation
19
27
  * @param {Object} memberData - The member data to validate
@@ -196,7 +204,189 @@ export function validateMemberUpdateData(updateData) {
196
204
  }
197
205
  }
198
206
 
207
+ /**
208
+ * Sanitizes a value for use in NQL filters to prevent injection
209
+ * Escapes backslashes, single quotes, and double quotes
210
+ * @param {string} value - The value to sanitize
211
+ * @returns {string} The sanitized value
212
+ */
213
+ export function sanitizeNqlValue(value) {
214
+ if (!value) return value;
215
+ // Escape backslashes first, then quotes
216
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
217
+ }
218
+
219
+ /**
220
+ * Validates query options for member browsing
221
+ * @param {Object} options - The query options to validate
222
+ * @param {number} [options.limit] - Number of members to return (1-100)
223
+ * @param {number} [options.page] - Page number (1+)
224
+ * @param {string} [options.filter] - NQL filter string
225
+ * @param {string} [options.order] - Order string (e.g., 'created_at desc')
226
+ * @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
227
+ * @throws {ValidationError} If validation fails
228
+ */
229
+ export function validateMemberQueryOptions(options) {
230
+ const errors = [];
231
+
232
+ // Validate limit
233
+ if (options.limit !== undefined) {
234
+ if (
235
+ typeof options.limit !== 'number' ||
236
+ options.limit < MIN_LIMIT ||
237
+ options.limit > MAX_LIMIT
238
+ ) {
239
+ errors.push({
240
+ field: 'limit',
241
+ message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_LIMIT}`,
242
+ });
243
+ }
244
+ }
245
+
246
+ // Validate page
247
+ if (options.page !== undefined) {
248
+ if (typeof options.page !== 'number' || options.page < MIN_PAGE) {
249
+ errors.push({
250
+ field: 'page',
251
+ message: `Page must be a number >= ${MIN_PAGE}`,
252
+ });
253
+ }
254
+ }
255
+
256
+ // Validate filter (must be non-empty string if provided)
257
+ if (options.filter !== undefined) {
258
+ if (typeof options.filter !== 'string' || options.filter.trim().length === 0) {
259
+ errors.push({
260
+ field: 'filter',
261
+ message: 'Filter must be a non-empty string',
262
+ });
263
+ }
264
+ }
265
+
266
+ // Validate order (must be non-empty string if provided)
267
+ if (options.order !== undefined) {
268
+ if (typeof options.order !== 'string' || options.order.trim().length === 0) {
269
+ errors.push({
270
+ field: 'order',
271
+ message: 'Order must be a non-empty string',
272
+ });
273
+ }
274
+ }
275
+
276
+ // Validate include (must be non-empty string if provided)
277
+ if (options.include !== undefined) {
278
+ if (typeof options.include !== 'string' || options.include.trim().length === 0) {
279
+ errors.push({
280
+ field: 'include',
281
+ message: 'Include must be a non-empty string',
282
+ });
283
+ }
284
+ }
285
+
286
+ if (errors.length > 0) {
287
+ throw new ValidationError('Member query validation failed', errors);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Validates member lookup parameters (id OR email required)
293
+ * @param {Object} params - The lookup parameters
294
+ * @param {string} [params.id] - Member ID
295
+ * @param {string} [params.email] - Member email
296
+ * @returns {Object} Normalized params with lookupType ('id' or 'email')
297
+ * @throws {ValidationError} If validation fails
298
+ */
299
+ export function validateMemberLookup(params) {
300
+ const errors = [];
301
+
302
+ // Check if id is provided and valid
303
+ const hasValidId = params.id && typeof params.id === 'string' && params.id.trim().length > 0;
304
+
305
+ // Check if email is provided and valid
306
+ const hasEmail = params.email !== undefined;
307
+ const hasValidEmail =
308
+ hasEmail && typeof params.email === 'string' && EMAIL_REGEX.test(params.email);
309
+
310
+ // Must have at least one valid identifier
311
+ if (!hasValidId && !hasValidEmail) {
312
+ if (params.id !== undefined && !hasValidId) {
313
+ errors.push({ field: 'id', message: 'ID must be a non-empty string' });
314
+ }
315
+ if (hasEmail && !hasValidEmail) {
316
+ errors.push({ field: 'email', message: 'Invalid email format' });
317
+ }
318
+ if (!params.id && !params.email) {
319
+ errors.push({ field: 'id|email', message: 'Either id or email is required' });
320
+ }
321
+
322
+ throw new ValidationError('Member lookup validation failed', errors);
323
+ }
324
+
325
+ // Return normalized result - ID takes precedence if both provided
326
+ if (hasValidId) {
327
+ return { id: params.id, lookupType: 'id' };
328
+ }
329
+ return { email: params.email, lookupType: 'email' };
330
+ }
331
+
332
+ /**
333
+ * Validates and sanitizes a search query
334
+ * @param {string} query - The search query
335
+ * @returns {string} The sanitized query
336
+ * @throws {ValidationError} If validation fails
337
+ */
338
+ export function validateSearchQuery(query) {
339
+ const errors = [];
340
+
341
+ if (query === null || query === undefined || typeof query !== 'string') {
342
+ errors.push({ field: 'query', message: 'Query must be a string' });
343
+ throw new ValidationError('Search query validation failed', errors);
344
+ }
345
+
346
+ const trimmedQuery = query.trim();
347
+ if (trimmedQuery.length === 0) {
348
+ errors.push({ field: 'query', message: 'Query must not be empty' });
349
+ throw new ValidationError('Search query validation failed', errors);
350
+ }
351
+
352
+ return trimmedQuery;
353
+ }
354
+
355
+ /**
356
+ * Validates search options (specifically limit for search operations)
357
+ * Search has a lower max limit (50) than browse operations (100)
358
+ * @param {Object} options - The search options to validate
359
+ * @param {number} [options.limit] - Maximum number of results (1-50)
360
+ * @throws {ValidationError} If validation fails
361
+ */
362
+ export function validateSearchOptions(options) {
363
+ const errors = [];
364
+
365
+ // Validate limit for search (1-50, lower than browse)
366
+ if (options.limit !== undefined) {
367
+ if (
368
+ typeof options.limit !== 'number' ||
369
+ options.limit < MIN_LIMIT ||
370
+ options.limit > MAX_SEARCH_LIMIT
371
+ ) {
372
+ errors.push({
373
+ field: 'limit',
374
+ message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_SEARCH_LIMIT}`,
375
+ });
376
+ }
377
+ }
378
+
379
+ if (errors.length > 0) {
380
+ throw new ValidationError('Search options validation failed', errors);
381
+ }
382
+ }
383
+
199
384
  export default {
200
385
  validateMemberData,
201
386
  validateMemberUpdateData,
387
+ validateMemberQueryOptions,
388
+ validateMemberLookup,
389
+ validateSearchQuery,
390
+ validateSearchOptions,
391
+ sanitizeNqlValue,
202
392
  };