@jgardner04/ghost-mcp-server 1.13.1 → 1.13.3

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.
Files changed (35) hide show
  1. package/package.json +6 -18
  2. package/src/__tests__/mcp_server.test.js +204 -117
  3. package/src/__tests__/mcp_server_pages.test.js +32 -18
  4. package/src/config/mcp-config.js +1 -1
  5. package/src/controllers/__tests__/tagController.test.js +12 -8
  6. package/src/controllers/tagController.js +2 -2
  7. package/src/errors/__tests__/index.test.js +3 -3
  8. package/src/errors/index.js +1 -1
  9. package/src/index.js +1 -1
  10. package/src/mcp_server.js +37 -33
  11. package/src/schemas/__tests__/postSchemas.test.js +19 -0
  12. package/src/schemas/__tests__/tagSchemas.test.js +1 -1
  13. package/src/schemas/common.js +2 -2
  14. package/src/schemas/memberSchemas.js +20 -8
  15. package/src/schemas/newsletterSchemas.js +10 -10
  16. package/src/schemas/pageSchemas.js +16 -13
  17. package/src/schemas/postSchemas.js +22 -13
  18. package/src/schemas/tagSchemas.js +12 -7
  19. package/src/schemas/tierSchemas.js +17 -8
  20. package/src/services/__tests__/ghostServiceImproved.members.test.js +15 -6
  21. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +21 -12
  22. package/src/services/__tests__/ghostServiceImproved.pages.test.js +59 -16
  23. package/src/services/__tests__/ghostServiceImproved.posts.test.js +233 -0
  24. package/src/services/__tests__/ghostServiceImproved.tags.test.js +13 -2
  25. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +18 -19
  26. package/src/services/__tests__/memberService.test.js +0 -28
  27. package/src/services/__tests__/tierService.test.js +0 -28
  28. package/src/services/ghostServiceImproved.js +108 -379
  29. package/src/services/imageProcessingService.js +1 -1
  30. package/src/services/memberService.js +0 -13
  31. package/src/services/tierService.js +0 -13
  32. package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
  33. package/src/utils/__tests__/urlValidator.test.js +137 -1
  34. package/src/utils/nqlSanitizer.js +11 -0
  35. package/src/utils/urlValidator.js +25 -2
@@ -1,5 +1,4 @@
1
1
  import GhostAdminAPI from '@tryghost/admin-api';
2
- import sanitizeHtml from 'sanitize-html';
3
2
  import dotenv from 'dotenv';
4
3
  import { promises as fs } from 'fs';
5
4
  import {
@@ -12,6 +11,7 @@ import {
12
11
  retryWithBackoff,
13
12
  } from '../errors/index.js';
14
13
  import { createContextLogger } from '../utils/logger.js';
14
+ import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
15
15
 
16
16
  dotenv.config();
17
17
 
@@ -118,6 +118,30 @@ const handleApiRequest = async (resource, action, data = {}, options = {}, confi
118
118
  * Input validation helpers
119
119
  */
120
120
  const validators = {
121
+ validateScheduledStatus(data, resourceLabel = 'Resource') {
122
+ const errors = [];
123
+
124
+ if (data.status === 'scheduled' && !data.published_at) {
125
+ errors.push({
126
+ field: 'published_at',
127
+ message: 'published_at is required when status is scheduled',
128
+ });
129
+ }
130
+
131
+ if (data.published_at) {
132
+ const publishDate = new Date(data.published_at);
133
+ if (isNaN(publishDate.getTime())) {
134
+ errors.push({ field: 'published_at', message: 'Invalid date format' });
135
+ } else if (data.status === 'scheduled' && publishDate <= new Date()) {
136
+ errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
137
+ }
138
+ }
139
+
140
+ if (errors.length > 0) {
141
+ throw new ValidationError(`${resourceLabel} validation failed`, errors);
142
+ }
143
+ },
144
+
121
145
  validatePostData(postData) {
122
146
  const errors = [];
123
147
 
@@ -136,25 +160,11 @@ const validators = {
136
160
  });
137
161
  }
138
162
 
139
- if (postData.status === 'scheduled' && !postData.published_at) {
140
- errors.push({
141
- field: 'published_at',
142
- message: 'published_at is required when status is scheduled',
143
- });
144
- }
145
-
146
- if (postData.published_at) {
147
- const publishDate = new Date(postData.published_at);
148
- if (isNaN(publishDate.getTime())) {
149
- errors.push({ field: 'published_at', message: 'Invalid date format' });
150
- } else if (postData.status === 'scheduled' && publishDate <= new Date()) {
151
- errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
152
- }
153
- }
154
-
155
163
  if (errors.length > 0) {
156
164
  throw new ValidationError('Post validation failed', errors);
157
165
  }
166
+
167
+ this.validateScheduledStatus(postData, 'Post');
158
168
  },
159
169
 
160
170
  validateTagData(tagData) {
@@ -228,25 +238,11 @@ const validators = {
228
238
  });
229
239
  }
230
240
 
231
- if (pageData.status === 'scheduled' && !pageData.published_at) {
232
- errors.push({
233
- field: 'published_at',
234
- message: 'published_at is required when status is scheduled',
235
- });
236
- }
237
-
238
- if (pageData.published_at) {
239
- const publishDate = new Date(pageData.published_at);
240
- if (isNaN(publishDate.getTime())) {
241
- errors.push({ field: 'published_at', message: 'Invalid date format' });
242
- } else if (pageData.status === 'scheduled' && publishDate <= new Date()) {
243
- errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
244
- }
245
- }
246
-
247
241
  if (errors.length > 0) {
248
242
  throw new ValidationError('Page validation failed', errors);
249
243
  }
244
+
245
+ this.validateScheduledStatus(pageData, 'Page');
250
246
  },
251
247
 
252
248
  validateNewsletterData(newsletterData) {
@@ -262,6 +258,53 @@ const validators = {
262
258
  },
263
259
  };
264
260
 
261
+ /**
262
+ * Shared CRUD helper: read a single resource by ID with 404→NotFoundError handling
263
+ */
264
+ async function readResource(resource, id, label, options = {}) {
265
+ if (!id) throw new ValidationError(`${label} ID is required`);
266
+ try {
267
+ return await handleApiRequest(resource, 'read', { id }, options);
268
+ } catch (error) {
269
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
270
+ throw new NotFoundError(label, id);
271
+ }
272
+ throw error;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Shared CRUD helper: update a resource with optimistic concurrency control (OCC)
278
+ * Reads the current version, merges updated_at, then edits.
279
+ */
280
+ async function updateWithOCC(resource, id, updateData, options = {}, label = resource) {
281
+ const existing = await readResource(resource, id, label);
282
+ const editData = { ...updateData, updated_at: existing.updated_at };
283
+ try {
284
+ return await handleApiRequest(resource, 'edit', { id, ...editData }, options);
285
+ } catch (error) {
286
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
287
+ throw new NotFoundError(label, id);
288
+ }
289
+ throw error;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Shared CRUD helper: delete a resource by ID with 404→NotFoundError handling
295
+ */
296
+ async function deleteResource(resource, id, label) {
297
+ if (!id) throw new ValidationError(`${label} ID is required for deletion`);
298
+ try {
299
+ return await handleApiRequest(resource, 'delete', { id });
300
+ } catch (error) {
301
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
302
+ throw new NotFoundError(label, id);
303
+ }
304
+ throw error;
305
+ }
306
+ }
307
+
265
308
  /**
266
309
  * Service functions with enhanced error handling
267
310
  */
@@ -285,48 +328,7 @@ export async function createPost(postData, options = { source: 'html' }) {
285
328
  ...postData,
286
329
  };
287
330
 
288
- // Sanitize HTML content if provided
289
- if (dataWithDefaults.html) {
290
- // Use proper HTML sanitization library to prevent XSS
291
- dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
292
- allowedTags: [
293
- 'h1',
294
- 'h2',
295
- 'h3',
296
- 'h4',
297
- 'h5',
298
- 'h6',
299
- 'blockquote',
300
- 'p',
301
- 'a',
302
- 'ul',
303
- 'ol',
304
- 'nl',
305
- 'li',
306
- 'b',
307
- 'i',
308
- 'strong',
309
- 'em',
310
- 'strike',
311
- 'code',
312
- 'hr',
313
- 'br',
314
- 'div',
315
- 'span',
316
- 'img',
317
- 'pre',
318
- ],
319
- allowedAttributes: {
320
- a: ['href', 'title'],
321
- img: ['src', 'alt', 'title', 'width', 'height'],
322
- '*': ['class', 'id'],
323
- },
324
- allowedSchemes: ['http', 'https', 'mailto'],
325
- allowedSchemesByTag: {
326
- img: ['http', 'https', 'data'],
327
- },
328
- });
329
- }
331
+ // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
330
332
 
331
333
  try {
332
334
  return await handleApiRequest('posts', 'add', dataWithDefaults, options);
@@ -346,54 +348,20 @@ export async function updatePost(postId, updateData, options = {}) {
346
348
  throw new ValidationError('Post ID is required for update');
347
349
  }
348
350
 
349
- // Get the current post first to ensure it exists
350
- try {
351
- const existingPost = await handleApiRequest('posts', 'read', { id: postId });
352
-
353
- // Merge with existing data
354
- const mergedData = {
355
- ...existingPost,
356
- ...updateData,
357
- updated_at: existingPost.updated_at, // Required for Ghost API
358
- };
359
-
360
- return await handleApiRequest('posts', 'edit', mergedData, { id: postId, ...options });
361
- } catch (error) {
362
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
363
- throw new NotFoundError('Post', postId);
364
- }
365
- throw error;
351
+ // Validate scheduled status if status is being updated
352
+ if (updateData.status) {
353
+ validators.validateScheduledStatus(updateData, 'Post');
366
354
  }
355
+
356
+ return updateWithOCC('posts', postId, updateData, options, 'Post');
367
357
  }
368
358
 
369
359
  export async function deletePost(postId) {
370
- if (!postId) {
371
- throw new ValidationError('Post ID is required for deletion');
372
- }
373
-
374
- try {
375
- return await handleApiRequest('posts', 'delete', { id: postId });
376
- } catch (error) {
377
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
378
- throw new NotFoundError('Post', postId);
379
- }
380
- throw error;
381
- }
360
+ return deleteResource('posts', postId, 'Post');
382
361
  }
383
362
 
384
363
  export async function getPost(postId, options = {}) {
385
- if (!postId) {
386
- throw new ValidationError('Post ID is required');
387
- }
388
-
389
- try {
390
- return await handleApiRequest('posts', 'read', { id: postId }, options);
391
- } catch (error) {
392
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
393
- throw new NotFoundError('Post', postId);
394
- }
395
- throw error;
396
- }
364
+ return readResource('posts', postId, 'Post', options);
397
365
  }
398
366
 
399
367
  export async function getPosts(options = {}) {
@@ -418,7 +386,7 @@ export async function searchPosts(query, options = {}) {
418
386
  }
419
387
 
420
388
  // Sanitize query - escape special NQL characters to prevent injection
421
- const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
389
+ const sanitizedQuery = sanitizeNqlValue(query);
422
390
 
423
391
  // Build filter with fuzzy title match using Ghost NQL
424
392
  const filterParts = [`title:~'${sanitizedQuery}'`];
@@ -457,47 +425,7 @@ export async function createPage(pageData, options = { source: 'html' }) {
457
425
  ...pageData,
458
426
  };
459
427
 
460
- // Sanitize HTML content if provided (use same sanitization as posts)
461
- if (dataWithDefaults.html) {
462
- dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
463
- allowedTags: [
464
- 'h1',
465
- 'h2',
466
- 'h3',
467
- 'h4',
468
- 'h5',
469
- 'h6',
470
- 'blockquote',
471
- 'p',
472
- 'a',
473
- 'ul',
474
- 'ol',
475
- 'nl',
476
- 'li',
477
- 'b',
478
- 'i',
479
- 'strong',
480
- 'em',
481
- 'strike',
482
- 'code',
483
- 'hr',
484
- 'br',
485
- 'div',
486
- 'span',
487
- 'img',
488
- 'pre',
489
- ],
490
- allowedAttributes: {
491
- a: ['href', 'title'],
492
- img: ['src', 'alt', 'title', 'width', 'height'],
493
- '*': ['class', 'id'],
494
- },
495
- allowedSchemes: ['http', 'https', 'mailto'],
496
- allowedSchemesByTag: {
497
- img: ['http', 'https', 'data'],
498
- },
499
- });
500
- }
428
+ // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
501
429
 
502
430
  try {
503
431
  return await handleApiRequest('pages', 'add', dataWithDefaults, options);
@@ -516,96 +444,22 @@ export async function updatePage(pageId, updateData, options = {}) {
516
444
  throw new ValidationError('Page ID is required for update');
517
445
  }
518
446
 
519
- // Sanitize HTML if being updated
520
- if (updateData.html) {
521
- updateData.html = sanitizeHtml(updateData.html, {
522
- allowedTags: [
523
- 'h1',
524
- 'h2',
525
- 'h3',
526
- 'h4',
527
- 'h5',
528
- 'h6',
529
- 'blockquote',
530
- 'p',
531
- 'a',
532
- 'ul',
533
- 'ol',
534
- 'nl',
535
- 'li',
536
- 'b',
537
- 'i',
538
- 'strong',
539
- 'em',
540
- 'strike',
541
- 'code',
542
- 'hr',
543
- 'br',
544
- 'div',
545
- 'span',
546
- 'img',
547
- 'pre',
548
- ],
549
- allowedAttributes: {
550
- a: ['href', 'title'],
551
- img: ['src', 'alt', 'title', 'width', 'height'],
552
- '*': ['class', 'id'],
553
- },
554
- allowedSchemes: ['http', 'https', 'mailto'],
555
- allowedSchemesByTag: {
556
- img: ['http', 'https', 'data'],
557
- },
558
- });
559
- }
560
-
561
- try {
562
- // Get existing page to retrieve updated_at for conflict resolution
563
- const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
564
-
565
- // Merge existing data with updates, preserving updated_at
566
- const mergedData = {
567
- ...existingPage,
568
- ...updateData,
569
- updated_at: existingPage.updated_at,
570
- };
447
+ // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
571
448
 
572
- return await handleApiRequest('pages', 'edit', mergedData, { id: pageId, ...options });
573
- } catch (error) {
574
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
575
- throw new NotFoundError('Page', pageId);
576
- }
577
- throw error;
449
+ // Validate scheduled status if status is being updated
450
+ if (updateData.status) {
451
+ validators.validateScheduledStatus(updateData, 'Page');
578
452
  }
453
+
454
+ return updateWithOCC('pages', pageId, updateData, options, 'Page');
579
455
  }
580
456
 
581
457
  export async function deletePage(pageId) {
582
- if (!pageId) {
583
- throw new ValidationError('Page ID is required for delete');
584
- }
585
-
586
- try {
587
- return await handleApiRequest('pages', 'delete', { id: pageId });
588
- } catch (error) {
589
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
590
- throw new NotFoundError('Page', pageId);
591
- }
592
- throw error;
593
- }
458
+ return deleteResource('pages', pageId, 'Page');
594
459
  }
595
460
 
596
461
  export async function getPage(pageId, options = {}) {
597
- if (!pageId) {
598
- throw new ValidationError('Page ID is required');
599
- }
600
-
601
- try {
602
- return await handleApiRequest('pages', 'read', { id: pageId }, options);
603
- } catch (error) {
604
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
605
- throw new NotFoundError('Page', pageId);
606
- }
607
- throw error;
608
- }
462
+ return readResource('pages', pageId, 'Page', options);
609
463
  }
610
464
 
611
465
  export async function getPages(options = {}) {
@@ -630,7 +484,7 @@ export async function searchPages(query, options = {}) {
630
484
  }
631
485
 
632
486
  // Sanitize query - escape special NQL characters to prevent injection
633
- const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
487
+ const sanitizedQuery = sanitizeNqlValue(query);
634
488
 
635
489
  // Build filter with fuzzy title match using Ghost NQL
636
490
  const filterParts = [`title:~'${sanitizedQuery}'`];
@@ -721,18 +575,7 @@ export async function getTags(options = {}) {
721
575
  }
722
576
 
723
577
  export async function getTag(tagId, options = {}) {
724
- if (!tagId) {
725
- throw new ValidationError('Tag ID is required');
726
- }
727
-
728
- try {
729
- return await handleApiRequest('tags', 'read', { id: tagId }, options);
730
- } catch (error) {
731
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
732
- throw new NotFoundError('Tag', tagId);
733
- }
734
- throw error;
735
- }
578
+ return readResource('tags', tagId, 'Tag', options);
736
579
  }
737
580
 
738
581
  export async function updateTag(tagId, updateData) {
@@ -740,16 +583,11 @@ export async function updateTag(tagId, updateData) {
740
583
  throw new ValidationError('Tag ID is required for update');
741
584
  }
742
585
 
743
- validators.validateTagUpdateData(updateData); // Validate update data
586
+ validators.validateTagUpdateData(updateData);
744
587
 
745
588
  try {
746
- const existingTag = await getTag(tagId);
747
- const mergedData = {
748
- ...existingTag,
749
- ...updateData,
750
- };
751
-
752
- return await handleApiRequest('tags', 'edit', mergedData, { id: tagId });
589
+ await readResource('tags', tagId, 'Tag');
590
+ return await handleApiRequest('tags', 'edit', { id: tagId, ...updateData });
753
591
  } catch (error) {
754
592
  if (error instanceof NotFoundError) {
755
593
  throw error;
@@ -764,18 +602,7 @@ export async function updateTag(tagId, updateData) {
764
602
  }
765
603
 
766
604
  export async function deleteTag(tagId) {
767
- if (!tagId) {
768
- throw new ValidationError('Tag ID is required for deletion');
769
- }
770
-
771
- try {
772
- return await handleApiRequest('tags', 'delete', { id: tagId });
773
- } catch (error) {
774
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
775
- throw new NotFoundError('Tag', tagId);
776
- }
777
- throw error;
778
- }
605
+ return deleteResource('tags', tagId, 'Tag');
779
606
  }
780
607
 
781
608
  /**
@@ -833,24 +660,7 @@ export async function updateMember(memberId, updateData, options = {}) {
833
660
  throw new ValidationError('Member ID is required for update');
834
661
  }
835
662
 
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
- }
663
+ return updateWithOCC('members', memberId, updateData, options, 'Member');
854
664
  }
855
665
 
856
666
  /**
@@ -862,18 +672,7 @@ export async function updateMember(memberId, updateData, options = {}) {
862
672
  * @throws {GhostAPIError} If the API request fails
863
673
  */
864
674
  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
- }
675
+ return deleteResource('members', memberId, 'Member');
877
676
  }
878
677
 
879
678
  /**
@@ -916,7 +715,6 @@ export async function getMembers(options = {}) {
916
715
  */
917
716
  export async function getMember(params) {
918
717
  // Input validation is performed at the MCP tool layer using Zod schemas
919
- const { sanitizeNqlValue } = await import('./memberService.js');
920
718
  const { id, email } = params;
921
719
 
922
720
  try {
@@ -958,7 +756,6 @@ export async function getMember(params) {
958
756
  */
959
757
  export async function searchMembers(query, options = {}) {
960
758
  // Input validation is performed at the MCP tool layer using Zod schemas
961
- const { sanitizeNqlValue } = await import('./memberService.js');
962
759
  const sanitizedQuery = sanitizeNqlValue(query.trim());
963
760
 
964
761
  const limit = options.limit || 15;
@@ -996,18 +793,7 @@ export async function getNewsletters(options = {}) {
996
793
  }
997
794
 
998
795
  export async function getNewsletter(newsletterId) {
999
- if (!newsletterId) {
1000
- throw new ValidationError('Newsletter ID is required');
1001
- }
1002
-
1003
- try {
1004
- return await handleApiRequest('newsletters', 'read', { id: newsletterId });
1005
- } catch (error) {
1006
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1007
- throw new NotFoundError('Newsletter', newsletterId);
1008
- }
1009
- throw error;
1010
- }
796
+ return readResource('newsletters', newsletterId, 'Newsletter');
1011
797
  }
1012
798
 
1013
799
  export async function createNewsletter(newsletterData) {
@@ -1032,23 +818,8 @@ export async function updateNewsletter(newsletterId, updateData) {
1032
818
  }
1033
819
 
1034
820
  try {
1035
- // Get existing newsletter to retrieve updated_at for conflict resolution
1036
- const existingNewsletter = await handleApiRequest('newsletters', 'read', {
1037
- id: newsletterId,
1038
- });
1039
-
1040
- // Merge existing data with updates, preserving updated_at
1041
- const mergedData = {
1042
- ...existingNewsletter,
1043
- ...updateData,
1044
- updated_at: existingNewsletter.updated_at,
1045
- };
1046
-
1047
- return await handleApiRequest('newsletters', 'edit', mergedData, { id: newsletterId });
821
+ return await updateWithOCC('newsletters', newsletterId, updateData, {}, 'Newsletter');
1048
822
  } catch (error) {
1049
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1050
- throw new NotFoundError('Newsletter', newsletterId);
1051
- }
1052
823
  if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
1053
824
  throw new ValidationError('Newsletter update failed', [
1054
825
  { field: 'newsletter', message: error.originalError },
@@ -1059,18 +830,7 @@ export async function updateNewsletter(newsletterId, updateData) {
1059
830
  }
1060
831
 
1061
832
  export async function deleteNewsletter(newsletterId) {
1062
- if (!newsletterId) {
1063
- throw new ValidationError('Newsletter ID is required for deletion');
1064
- }
1065
-
1066
- try {
1067
- return await handleApiRequest('newsletters', 'delete', newsletterId);
1068
- } catch (error) {
1069
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1070
- throw new NotFoundError('Newsletter', newsletterId);
1071
- }
1072
- throw error;
1073
- }
833
+ return deleteResource('newsletters', newsletterId, 'Newsletter');
1074
834
  }
1075
835
 
1076
836
  /**
@@ -1110,24 +870,7 @@ export async function updateTier(id, updateData, options = {}) {
1110
870
  const { validateTierUpdateData } = await import('./tierService.js');
1111
871
  validateTierUpdateData(updateData);
1112
872
 
1113
- try {
1114
- // Get existing tier for merge
1115
- const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
1116
-
1117
- // Merge updates with existing data
1118
- const mergedData = {
1119
- ...existingTier,
1120
- ...updateData,
1121
- updated_at: existingTier.updated_at,
1122
- };
1123
-
1124
- return await handleApiRequest('tiers', 'edit', mergedData, { id, ...options });
1125
- } catch (error) {
1126
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1127
- throw new NotFoundError('Tier', id);
1128
- }
1129
- throw error;
1130
- }
873
+ return updateWithOCC('tiers', id, updateData, options, 'Tier');
1131
874
  }
1132
875
 
1133
876
  /**
@@ -1140,14 +883,7 @@ export async function deleteTier(id) {
1140
883
  throw new ValidationError('Tier ID is required for deletion');
1141
884
  }
1142
885
 
1143
- try {
1144
- return await handleApiRequest('tiers', 'delete', { id });
1145
- } catch (error) {
1146
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1147
- throw new NotFoundError('Tier', id);
1148
- }
1149
- throw error;
1150
- }
886
+ return deleteResource('tiers', id, 'Tier');
1151
887
  }
1152
888
 
1153
889
  /**
@@ -1188,14 +924,7 @@ export async function getTier(id) {
1188
924
  throw new ValidationError('Tier ID is required and must be a non-empty string');
1189
925
  }
1190
926
 
1191
- try {
1192
- return await handleApiRequest('tiers', 'read', { id }, { id });
1193
- } catch (error) {
1194
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1195
- throw new NotFoundError('Tier', id);
1196
- }
1197
- throw error;
1198
- }
927
+ return readResource('tiers', id, 'Tier');
1199
928
  }
1200
929
 
1201
930
  /**
@@ -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