@jgardner04/ghost-mcp-server 1.13.2 → 1.13.4
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 +5 -13
- package/src/__tests__/helpers/mockGhostApi.js +36 -0
- package/src/__tests__/mcp_server.test.js +204 -117
- package/src/__tests__/mcp_server_pages.test.js +32 -18
- package/src/config/mcp-config.js +1 -1
- package/src/controllers/__tests__/tagController.test.js +12 -8
- package/src/controllers/tagController.js +2 -2
- package/src/errors/__tests__/index.test.js +3 -3
- package/src/errors/index.js +1 -1
- package/src/index.js +1 -1
- package/src/mcp_server.js +35 -31
- package/src/schemas/__tests__/postSchemas.test.js +19 -0
- package/src/schemas/__tests__/tagSchemas.test.js +1 -1
- package/src/schemas/common.js +2 -2
- package/src/schemas/memberSchemas.js +20 -8
- package/src/schemas/newsletterSchemas.js +10 -10
- package/src/schemas/pageSchemas.js +16 -11
- package/src/schemas/postSchemas.js +22 -15
- package/src/schemas/tagSchemas.js +12 -7
- package/src/schemas/tierSchemas.js +17 -8
- package/src/services/__tests__/ghostServiceImproved.members.test.js +31 -62
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +66 -69
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +77 -48
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +69 -55
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +29 -66
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +12 -62
- package/src/services/__tests__/memberService.test.js +0 -28
- package/src/services/__tests__/tierService.test.js +0 -28
- package/src/services/ghostServiceImproved.js +117 -299
- package/src/services/imageProcessingService.js +1 -1
- package/src/services/memberService.js +0 -13
- package/src/services/tierService.js +0 -13
- package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
- package/src/utils/nqlSanitizer.js +11 -0
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
retryWithBackoff,
|
|
12
12
|
} from '../errors/index.js';
|
|
13
13
|
import { createContextLogger } from '../utils/logger.js';
|
|
14
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
14
15
|
|
|
15
16
|
dotenv.config();
|
|
16
17
|
|
|
@@ -56,7 +57,7 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
|
|
|
56
57
|
// Main execution function
|
|
57
58
|
const executeRequest = async () => {
|
|
58
59
|
try {
|
|
59
|
-
|
|
60
|
+
logger.info('Executing Ghost API request', { operation });
|
|
60
61
|
|
|
61
62
|
let result;
|
|
62
63
|
|
|
@@ -80,7 +81,7 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
|
|
|
80
81
|
result = await api[resource][action](data);
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
logger.info('Successfully executed Ghost API request', { operation });
|
|
84
85
|
return result;
|
|
85
86
|
} catch (error) {
|
|
86
87
|
// Transform Ghost API errors into our error types
|
|
@@ -98,17 +99,21 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
|
|
|
98
99
|
return await retryWithBackoff(wrappedExecute, {
|
|
99
100
|
maxAttempts: maxRetries,
|
|
100
101
|
onRetry: (attempt, _error) => {
|
|
101
|
-
|
|
102
|
+
logger.info('Retrying Ghost API request', { operation, attempt, maxRetries });
|
|
102
103
|
|
|
103
104
|
// Log circuit breaker state if relevant
|
|
104
105
|
if (useCircuitBreaker) {
|
|
105
106
|
const state = ghostCircuitBreaker.getState();
|
|
106
|
-
|
|
107
|
+
logger.info('Circuit breaker state', { operation, state });
|
|
107
108
|
}
|
|
108
109
|
},
|
|
109
110
|
});
|
|
110
111
|
} catch (error) {
|
|
111
|
-
|
|
112
|
+
logger.error('Failed to execute Ghost API request', {
|
|
113
|
+
operation,
|
|
114
|
+
maxRetries,
|
|
115
|
+
error: error.message,
|
|
116
|
+
});
|
|
112
117
|
throw error;
|
|
113
118
|
}
|
|
114
119
|
};
|
|
@@ -258,18 +263,60 @@ const validators = {
|
|
|
258
263
|
};
|
|
259
264
|
|
|
260
265
|
/**
|
|
261
|
-
*
|
|
266
|
+
* Shared CRUD helper: read a single resource by ID with 404→NotFoundError handling
|
|
262
267
|
*/
|
|
268
|
+
async function readResource(resource, id, label, options = {}) {
|
|
269
|
+
if (!id) throw new ValidationError(`${label} ID is required`);
|
|
270
|
+
try {
|
|
271
|
+
return await handleApiRequest(resource, 'read', { id }, options);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
274
|
+
throw new NotFoundError(label, id);
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
263
279
|
|
|
264
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Shared CRUD helper: update a resource with optimistic concurrency control (OCC)
|
|
282
|
+
* Reads the current version, merges updated_at, then edits.
|
|
283
|
+
*/
|
|
284
|
+
async function updateWithOCC(resource, id, updateData, options = {}, label = resource) {
|
|
285
|
+
const existing = await readResource(resource, id, label);
|
|
286
|
+
const editData = { ...updateData, updated_at: existing.updated_at };
|
|
265
287
|
try {
|
|
266
|
-
return await handleApiRequest('
|
|
288
|
+
return await handleApiRequest(resource, 'edit', { id, ...editData }, options);
|
|
267
289
|
} catch (error) {
|
|
268
|
-
|
|
290
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
291
|
+
throw new NotFoundError(label, id);
|
|
292
|
+
}
|
|
269
293
|
throw error;
|
|
270
294
|
}
|
|
271
295
|
}
|
|
272
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Shared CRUD helper: delete a resource by ID with 404→NotFoundError handling
|
|
299
|
+
*/
|
|
300
|
+
async function deleteResource(resource, id, label) {
|
|
301
|
+
if (!id) throw new ValidationError(`${label} ID is required for deletion`);
|
|
302
|
+
try {
|
|
303
|
+
return await handleApiRequest(resource, 'delete', { id });
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
306
|
+
throw new NotFoundError(label, id);
|
|
307
|
+
}
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Service functions with enhanced error handling
|
|
314
|
+
*/
|
|
315
|
+
|
|
316
|
+
export async function getSiteInfo() {
|
|
317
|
+
return handleApiRequest('site', 'read');
|
|
318
|
+
}
|
|
319
|
+
|
|
273
320
|
export async function createPost(postData, options = { source: 'html' }) {
|
|
274
321
|
// Validate input
|
|
275
322
|
validators.validatePostData(postData);
|
|
@@ -300,58 +347,26 @@ export async function updatePost(postId, updateData, options = {}) {
|
|
|
300
347
|
throw new ValidationError('Post ID is required for update');
|
|
301
348
|
}
|
|
302
349
|
|
|
303
|
-
// Validate scheduled status
|
|
304
|
-
if (updateData.status) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const existingPost = await handleApiRequest('posts', 'read', { id: postId });
|
|
311
|
-
|
|
312
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
313
|
-
const editData = {
|
|
314
|
-
...updateData,
|
|
315
|
-
updated_at: existingPost.updated_at,
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
return await handleApiRequest('posts', 'edit', editData, { id: postId, ...options });
|
|
319
|
-
} catch (error) {
|
|
320
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
321
|
-
throw new NotFoundError('Post', postId);
|
|
350
|
+
// Validate scheduled status when status or published_at is being updated
|
|
351
|
+
if (updateData.status || updateData.published_at) {
|
|
352
|
+
let validationData = updateData;
|
|
353
|
+
// When only published_at changes, fetch existing status to check if post is scheduled
|
|
354
|
+
if (!updateData.status && updateData.published_at) {
|
|
355
|
+
const existing = await readResource('posts', postId, 'Post');
|
|
356
|
+
validationData = { ...updateData, status: existing.status };
|
|
322
357
|
}
|
|
323
|
-
|
|
358
|
+
validators.validateScheduledStatus(validationData, 'Post');
|
|
324
359
|
}
|
|
360
|
+
|
|
361
|
+
return updateWithOCC('posts', postId, updateData, options, 'Post');
|
|
325
362
|
}
|
|
326
363
|
|
|
327
364
|
export async function deletePost(postId) {
|
|
328
|
-
|
|
329
|
-
throw new ValidationError('Post ID is required for deletion');
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
return await handleApiRequest('posts', 'delete', { id: postId });
|
|
334
|
-
} catch (error) {
|
|
335
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
336
|
-
throw new NotFoundError('Post', postId);
|
|
337
|
-
}
|
|
338
|
-
throw error;
|
|
339
|
-
}
|
|
365
|
+
return deleteResource('posts', postId, 'Post');
|
|
340
366
|
}
|
|
341
367
|
|
|
342
368
|
export async function getPost(postId, options = {}) {
|
|
343
|
-
|
|
344
|
-
throw new ValidationError('Post ID is required');
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
try {
|
|
348
|
-
return await handleApiRequest('posts', 'read', { id: postId }, options);
|
|
349
|
-
} catch (error) {
|
|
350
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
351
|
-
throw new NotFoundError('Post', postId);
|
|
352
|
-
}
|
|
353
|
-
throw error;
|
|
354
|
-
}
|
|
369
|
+
return readResource('posts', postId, 'Post', options);
|
|
355
370
|
}
|
|
356
371
|
|
|
357
372
|
export async function getPosts(options = {}) {
|
|
@@ -361,12 +376,7 @@ export async function getPosts(options = {}) {
|
|
|
361
376
|
...options,
|
|
362
377
|
};
|
|
363
378
|
|
|
364
|
-
|
|
365
|
-
return await handleApiRequest('posts', 'browse', {}, defaultOptions);
|
|
366
|
-
} catch (error) {
|
|
367
|
-
console.error('Failed to get posts:', error);
|
|
368
|
-
throw error;
|
|
369
|
-
}
|
|
379
|
+
return handleApiRequest('posts', 'browse', {}, defaultOptions);
|
|
370
380
|
}
|
|
371
381
|
|
|
372
382
|
export async function searchPosts(query, options = {}) {
|
|
@@ -376,7 +386,7 @@ export async function searchPosts(query, options = {}) {
|
|
|
376
386
|
}
|
|
377
387
|
|
|
378
388
|
// Sanitize query - escape special NQL characters to prevent injection
|
|
379
|
-
const sanitizedQuery = query
|
|
389
|
+
const sanitizedQuery = sanitizeNqlValue(query);
|
|
380
390
|
|
|
381
391
|
// Build filter with fuzzy title match using Ghost NQL
|
|
382
392
|
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
@@ -392,12 +402,7 @@ export async function searchPosts(query, options = {}) {
|
|
|
392
402
|
filter: filterParts.join('+'),
|
|
393
403
|
};
|
|
394
404
|
|
|
395
|
-
|
|
396
|
-
return await handleApiRequest('posts', 'browse', {}, searchOptions);
|
|
397
|
-
} catch (error) {
|
|
398
|
-
console.error('Failed to search posts:', error);
|
|
399
|
-
throw error;
|
|
400
|
-
}
|
|
405
|
+
return handleApiRequest('posts', 'browse', {}, searchOptions);
|
|
401
406
|
}
|
|
402
407
|
|
|
403
408
|
/**
|
|
@@ -436,58 +441,26 @@ export async function updatePage(pageId, updateData, options = {}) {
|
|
|
436
441
|
|
|
437
442
|
// SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
|
|
438
443
|
|
|
439
|
-
// Validate scheduled status
|
|
440
|
-
if (updateData.status) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
|
|
447
|
-
|
|
448
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
449
|
-
const editData = {
|
|
450
|
-
...updateData,
|
|
451
|
-
updated_at: existingPage.updated_at,
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
return await handleApiRequest('pages', 'edit', editData, { id: pageId, ...options });
|
|
455
|
-
} catch (error) {
|
|
456
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
457
|
-
throw new NotFoundError('Page', pageId);
|
|
444
|
+
// Validate scheduled status when status or published_at is being updated
|
|
445
|
+
if (updateData.status || updateData.published_at) {
|
|
446
|
+
let validationData = updateData;
|
|
447
|
+
// When only published_at changes, fetch existing status to check if page is scheduled
|
|
448
|
+
if (!updateData.status && updateData.published_at) {
|
|
449
|
+
const existing = await readResource('pages', pageId, 'Page');
|
|
450
|
+
validationData = { ...updateData, status: existing.status };
|
|
458
451
|
}
|
|
459
|
-
|
|
452
|
+
validators.validateScheduledStatus(validationData, 'Page');
|
|
460
453
|
}
|
|
454
|
+
|
|
455
|
+
return updateWithOCC('pages', pageId, updateData, options, 'Page');
|
|
461
456
|
}
|
|
462
457
|
|
|
463
458
|
export async function deletePage(pageId) {
|
|
464
|
-
|
|
465
|
-
throw new ValidationError('Page ID is required for delete');
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
try {
|
|
469
|
-
return await handleApiRequest('pages', 'delete', { id: pageId });
|
|
470
|
-
} catch (error) {
|
|
471
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
472
|
-
throw new NotFoundError('Page', pageId);
|
|
473
|
-
}
|
|
474
|
-
throw error;
|
|
475
|
-
}
|
|
459
|
+
return deleteResource('pages', pageId, 'Page');
|
|
476
460
|
}
|
|
477
461
|
|
|
478
462
|
export async function getPage(pageId, options = {}) {
|
|
479
|
-
|
|
480
|
-
throw new ValidationError('Page ID is required');
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
try {
|
|
484
|
-
return await handleApiRequest('pages', 'read', { id: pageId }, options);
|
|
485
|
-
} catch (error) {
|
|
486
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
487
|
-
throw new NotFoundError('Page', pageId);
|
|
488
|
-
}
|
|
489
|
-
throw error;
|
|
490
|
-
}
|
|
463
|
+
return readResource('pages', pageId, 'Page', options);
|
|
491
464
|
}
|
|
492
465
|
|
|
493
466
|
export async function getPages(options = {}) {
|
|
@@ -497,12 +470,7 @@ export async function getPages(options = {}) {
|
|
|
497
470
|
...options,
|
|
498
471
|
};
|
|
499
472
|
|
|
500
|
-
|
|
501
|
-
return await handleApiRequest('pages', 'browse', {}, defaultOptions);
|
|
502
|
-
} catch (error) {
|
|
503
|
-
console.error('Failed to get pages:', error);
|
|
504
|
-
throw error;
|
|
505
|
-
}
|
|
473
|
+
return handleApiRequest('pages', 'browse', {}, defaultOptions);
|
|
506
474
|
}
|
|
507
475
|
|
|
508
476
|
export async function searchPages(query, options = {}) {
|
|
@@ -512,7 +480,7 @@ export async function searchPages(query, options = {}) {
|
|
|
512
480
|
}
|
|
513
481
|
|
|
514
482
|
// Sanitize query - escape special NQL characters to prevent injection
|
|
515
|
-
const sanitizedQuery = query
|
|
483
|
+
const sanitizedQuery = sanitizeNqlValue(query);
|
|
516
484
|
|
|
517
485
|
// Build filter with fuzzy title match using Ghost NQL
|
|
518
486
|
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
@@ -528,12 +496,7 @@ export async function searchPages(query, options = {}) {
|
|
|
528
496
|
filter: filterParts.join('+'),
|
|
529
497
|
};
|
|
530
498
|
|
|
531
|
-
|
|
532
|
-
return await handleApiRequest('pages', 'browse', {}, searchOptions);
|
|
533
|
-
} catch (error) {
|
|
534
|
-
console.error('Failed to search pages:', error);
|
|
535
|
-
throw error;
|
|
536
|
-
}
|
|
499
|
+
return handleApiRequest('pages', 'browse', {}, searchOptions);
|
|
537
500
|
}
|
|
538
501
|
|
|
539
502
|
export async function uploadImage(imagePath) {
|
|
@@ -585,36 +548,20 @@ export async function createTag(tagData) {
|
|
|
585
548
|
}
|
|
586
549
|
|
|
587
550
|
export async function getTags(options = {}) {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
return tags || [];
|
|
599
|
-
} catch (error) {
|
|
600
|
-
logger.error('Failed to get tags', { error: error.message });
|
|
601
|
-
throw error;
|
|
602
|
-
}
|
|
551
|
+
const tags = await handleApiRequest(
|
|
552
|
+
'tags',
|
|
553
|
+
'browse',
|
|
554
|
+
{},
|
|
555
|
+
{
|
|
556
|
+
limit: 15,
|
|
557
|
+
...options,
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
return tags || [];
|
|
603
561
|
}
|
|
604
562
|
|
|
605
563
|
export async function getTag(tagId, options = {}) {
|
|
606
|
-
|
|
607
|
-
throw new ValidationError('Tag ID is required');
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
try {
|
|
611
|
-
return await handleApiRequest('tags', 'read', { id: tagId }, options);
|
|
612
|
-
} catch (error) {
|
|
613
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
614
|
-
throw new NotFoundError('Tag', tagId);
|
|
615
|
-
}
|
|
616
|
-
throw error;
|
|
617
|
-
}
|
|
564
|
+
return readResource('tags', tagId, 'Tag', options);
|
|
618
565
|
}
|
|
619
566
|
|
|
620
567
|
export async function updateTag(tagId, updateData) {
|
|
@@ -622,14 +569,11 @@ export async function updateTag(tagId, updateData) {
|
|
|
622
569
|
throw new ValidationError('Tag ID is required for update');
|
|
623
570
|
}
|
|
624
571
|
|
|
625
|
-
validators.validateTagUpdateData(updateData);
|
|
572
|
+
validators.validateTagUpdateData(updateData);
|
|
626
573
|
|
|
627
574
|
try {
|
|
628
|
-
|
|
629
|
-
await
|
|
630
|
-
|
|
631
|
-
// Send only changed fields (tags don't use updated_at for OCC)
|
|
632
|
-
return await handleApiRequest('tags', 'edit', { ...updateData }, { id: tagId });
|
|
575
|
+
await readResource('tags', tagId, 'Tag');
|
|
576
|
+
return await handleApiRequest('tags', 'edit', { id: tagId, ...updateData });
|
|
633
577
|
} catch (error) {
|
|
634
578
|
if (error instanceof NotFoundError) {
|
|
635
579
|
throw error;
|
|
@@ -644,18 +588,7 @@ export async function updateTag(tagId, updateData) {
|
|
|
644
588
|
}
|
|
645
589
|
|
|
646
590
|
export async function deleteTag(tagId) {
|
|
647
|
-
|
|
648
|
-
throw new ValidationError('Tag ID is required for deletion');
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
try {
|
|
652
|
-
return await handleApiRequest('tags', 'delete', { id: tagId });
|
|
653
|
-
} catch (error) {
|
|
654
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
655
|
-
throw new NotFoundError('Tag', tagId);
|
|
656
|
-
}
|
|
657
|
-
throw error;
|
|
658
|
-
}
|
|
591
|
+
return deleteResource('tags', tagId, 'Tag');
|
|
659
592
|
}
|
|
660
593
|
|
|
661
594
|
/**
|
|
@@ -713,23 +646,7 @@ export async function updateMember(memberId, updateData, options = {}) {
|
|
|
713
646
|
throw new ValidationError('Member ID is required for update');
|
|
714
647
|
}
|
|
715
648
|
|
|
716
|
-
|
|
717
|
-
// Get existing member to retrieve updated_at for conflict resolution
|
|
718
|
-
const existingMember = await handleApiRequest('members', 'read', { id: memberId });
|
|
719
|
-
|
|
720
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
721
|
-
const editData = {
|
|
722
|
-
...updateData,
|
|
723
|
-
updated_at: existingMember.updated_at,
|
|
724
|
-
};
|
|
725
|
-
|
|
726
|
-
return await handleApiRequest('members', 'edit', editData, { id: memberId, ...options });
|
|
727
|
-
} catch (error) {
|
|
728
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
729
|
-
throw new NotFoundError('Member', memberId);
|
|
730
|
-
}
|
|
731
|
-
throw error;
|
|
732
|
-
}
|
|
649
|
+
return updateWithOCC('members', memberId, updateData, options, 'Member');
|
|
733
650
|
}
|
|
734
651
|
|
|
735
652
|
/**
|
|
@@ -741,18 +658,7 @@ export async function updateMember(memberId, updateData, options = {}) {
|
|
|
741
658
|
* @throws {GhostAPIError} If the API request fails
|
|
742
659
|
*/
|
|
743
660
|
export async function deleteMember(memberId) {
|
|
744
|
-
|
|
745
|
-
throw new ValidationError('Member ID is required for deletion');
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
try {
|
|
749
|
-
return await handleApiRequest('members', 'delete', { id: memberId });
|
|
750
|
-
} catch (error) {
|
|
751
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
752
|
-
throw new NotFoundError('Member', memberId);
|
|
753
|
-
}
|
|
754
|
-
throw error;
|
|
755
|
-
}
|
|
661
|
+
return deleteResource('members', memberId, 'Member');
|
|
756
662
|
}
|
|
757
663
|
|
|
758
664
|
/**
|
|
@@ -774,13 +680,8 @@ export async function getMembers(options = {}) {
|
|
|
774
680
|
...options,
|
|
775
681
|
};
|
|
776
682
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
return members || [];
|
|
780
|
-
} catch (error) {
|
|
781
|
-
console.error('Failed to get members:', error);
|
|
782
|
-
throw error;
|
|
783
|
-
}
|
|
683
|
+
const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
|
|
684
|
+
return members || [];
|
|
784
685
|
}
|
|
785
686
|
|
|
786
687
|
/**
|
|
@@ -795,7 +696,6 @@ export async function getMembers(options = {}) {
|
|
|
795
696
|
*/
|
|
796
697
|
export async function getMember(params) {
|
|
797
698
|
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
798
|
-
const { sanitizeNqlValue } = await import('./memberService.js');
|
|
799
699
|
const { id, email } = params;
|
|
800
700
|
|
|
801
701
|
try {
|
|
@@ -837,7 +737,6 @@ export async function getMember(params) {
|
|
|
837
737
|
*/
|
|
838
738
|
export async function searchMembers(query, options = {}) {
|
|
839
739
|
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
840
|
-
const { sanitizeNqlValue } = await import('./memberService.js');
|
|
841
740
|
const sanitizedQuery = sanitizeNqlValue(query.trim());
|
|
842
741
|
|
|
843
742
|
const limit = options.limit || 15;
|
|
@@ -846,13 +745,8 @@ export async function searchMembers(query, options = {}) {
|
|
|
846
745
|
// Ghost uses ~ for contains/like matching
|
|
847
746
|
const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
|
|
848
747
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
return members || [];
|
|
852
|
-
} catch (error) {
|
|
853
|
-
console.error('Failed to search members:', error);
|
|
854
|
-
throw error;
|
|
855
|
-
}
|
|
748
|
+
const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
|
|
749
|
+
return members || [];
|
|
856
750
|
}
|
|
857
751
|
|
|
858
752
|
/**
|
|
@@ -865,28 +759,12 @@ export async function getNewsletters(options = {}) {
|
|
|
865
759
|
...options,
|
|
866
760
|
};
|
|
867
761
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
return newsletters || [];
|
|
871
|
-
} catch (error) {
|
|
872
|
-
console.error('Failed to get newsletters:', error);
|
|
873
|
-
throw error;
|
|
874
|
-
}
|
|
762
|
+
const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
|
|
763
|
+
return newsletters || [];
|
|
875
764
|
}
|
|
876
765
|
|
|
877
766
|
export async function getNewsletter(newsletterId) {
|
|
878
|
-
|
|
879
|
-
throw new ValidationError('Newsletter ID is required');
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
try {
|
|
883
|
-
return await handleApiRequest('newsletters', 'read', { id: newsletterId });
|
|
884
|
-
} catch (error) {
|
|
885
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
886
|
-
throw new NotFoundError('Newsletter', newsletterId);
|
|
887
|
-
}
|
|
888
|
-
throw error;
|
|
889
|
-
}
|
|
767
|
+
return readResource('newsletters', newsletterId, 'Newsletter');
|
|
890
768
|
}
|
|
891
769
|
|
|
892
770
|
export async function createNewsletter(newsletterData) {
|
|
@@ -911,22 +789,8 @@ export async function updateNewsletter(newsletterId, updateData) {
|
|
|
911
789
|
}
|
|
912
790
|
|
|
913
791
|
try {
|
|
914
|
-
|
|
915
|
-
const existingNewsletter = await handleApiRequest('newsletters', 'read', {
|
|
916
|
-
id: newsletterId,
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
920
|
-
const editData = {
|
|
921
|
-
...updateData,
|
|
922
|
-
updated_at: existingNewsletter.updated_at,
|
|
923
|
-
};
|
|
924
|
-
|
|
925
|
-
return await handleApiRequest('newsletters', 'edit', editData, { id: newsletterId });
|
|
792
|
+
return await updateWithOCC('newsletters', newsletterId, updateData, {}, 'Newsletter');
|
|
926
793
|
} catch (error) {
|
|
927
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
928
|
-
throw new NotFoundError('Newsletter', newsletterId);
|
|
929
|
-
}
|
|
930
794
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
931
795
|
throw new ValidationError('Newsletter update failed', [
|
|
932
796
|
{ field: 'newsletter', message: error.originalError },
|
|
@@ -937,18 +801,7 @@ export async function updateNewsletter(newsletterId, updateData) {
|
|
|
937
801
|
}
|
|
938
802
|
|
|
939
803
|
export async function deleteNewsletter(newsletterId) {
|
|
940
|
-
|
|
941
|
-
throw new ValidationError('Newsletter ID is required for deletion');
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
try {
|
|
945
|
-
return await handleApiRequest('newsletters', 'delete', newsletterId);
|
|
946
|
-
} catch (error) {
|
|
947
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
948
|
-
throw new NotFoundError('Newsletter', newsletterId);
|
|
949
|
-
}
|
|
950
|
-
throw error;
|
|
951
|
-
}
|
|
804
|
+
return deleteResource('newsletters', newsletterId, 'Newsletter');
|
|
952
805
|
}
|
|
953
806
|
|
|
954
807
|
/**
|
|
@@ -988,23 +841,7 @@ export async function updateTier(id, updateData, options = {}) {
|
|
|
988
841
|
const { validateTierUpdateData } = await import('./tierService.js');
|
|
989
842
|
validateTierUpdateData(updateData);
|
|
990
843
|
|
|
991
|
-
|
|
992
|
-
// Get existing tier to retrieve updated_at for conflict resolution
|
|
993
|
-
const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
|
|
994
|
-
|
|
995
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
996
|
-
const editData = {
|
|
997
|
-
...updateData,
|
|
998
|
-
updated_at: existingTier.updated_at,
|
|
999
|
-
};
|
|
1000
|
-
|
|
1001
|
-
return await handleApiRequest('tiers', 'edit', editData, { id, ...options });
|
|
1002
|
-
} catch (error) {
|
|
1003
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1004
|
-
throw new NotFoundError('Tier', id);
|
|
1005
|
-
}
|
|
1006
|
-
throw error;
|
|
1007
|
-
}
|
|
844
|
+
return updateWithOCC('tiers', id, updateData, options, 'Tier');
|
|
1008
845
|
}
|
|
1009
846
|
|
|
1010
847
|
/**
|
|
@@ -1017,14 +854,7 @@ export async function deleteTier(id) {
|
|
|
1017
854
|
throw new ValidationError('Tier ID is required for deletion');
|
|
1018
855
|
}
|
|
1019
856
|
|
|
1020
|
-
|
|
1021
|
-
return await handleApiRequest('tiers', 'delete', { id });
|
|
1022
|
-
} catch (error) {
|
|
1023
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1024
|
-
throw new NotFoundError('Tier', id);
|
|
1025
|
-
}
|
|
1026
|
-
throw error;
|
|
1027
|
-
}
|
|
857
|
+
return deleteResource('tiers', id, 'Tier');
|
|
1028
858
|
}
|
|
1029
859
|
|
|
1030
860
|
/**
|
|
@@ -1046,13 +876,8 @@ export async function getTiers(options = {}) {
|
|
|
1046
876
|
...options,
|
|
1047
877
|
};
|
|
1048
878
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
return tiers || [];
|
|
1052
|
-
} catch (error) {
|
|
1053
|
-
console.error('Failed to get tiers:', error);
|
|
1054
|
-
throw error;
|
|
1055
|
-
}
|
|
879
|
+
const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions);
|
|
880
|
+
return tiers || [];
|
|
1056
881
|
}
|
|
1057
882
|
|
|
1058
883
|
/**
|
|
@@ -1065,14 +890,7 @@ export async function getTier(id) {
|
|
|
1065
890
|
throw new ValidationError('Tier ID is required and must be a non-empty string');
|
|
1066
891
|
}
|
|
1067
892
|
|
|
1068
|
-
|
|
1069
|
-
return await handleApiRequest('tiers', 'read', { id }, { id });
|
|
1070
|
-
} catch (error) {
|
|
1071
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1072
|
-
throw new NotFoundError('Tier', id);
|
|
1073
|
-
}
|
|
1074
|
-
throw error;
|
|
1075
|
-
}
|
|
893
|
+
return readResource('tiers', id, 'Tier');
|
|
1076
894
|
}
|
|
1077
895
|
|
|
1078
896
|
/**
|
|
@@ -89,7 +89,7 @@ const processImage = async (inputPath, outputDir) => {
|
|
|
89
89
|
});
|
|
90
90
|
// If processing fails, maybe fall back to using the original?
|
|
91
91
|
// Or throw the error to fail the upload.
|
|
92
|
-
throw new Error('Image processing failed: ' + error.message);
|
|
92
|
+
throw new Error('Image processing failed: ' + error.message, { cause: error });
|
|
93
93
|
}
|
|
94
94
|
};
|
|
95
95
|
|
|
@@ -204,18 +204,6 @@ export function validateMemberUpdateData(updateData) {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
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
207
|
/**
|
|
220
208
|
* Validates query options for member browsing
|
|
221
209
|
* @param {Object} options - The query options to validate
|
|
@@ -388,5 +376,4 @@ export default {
|
|
|
388
376
|
validateMemberLookup,
|
|
389
377
|
validateSearchQuery,
|
|
390
378
|
validateSearchOptions,
|
|
391
|
-
sanitizeNqlValue,
|
|
392
379
|
};
|