Skip to main content
Glama
ghostServiceImproved.js35.7 kB
import GhostAdminAPI from '@tryghost/admin-api'; import sanitizeHtml from 'sanitize-html'; import dotenv from 'dotenv'; import { promises as fs } from 'fs'; import { GhostAPIError, ConfigurationError, ValidationError, NotFoundError, ErrorHandler, CircuitBreaker, retryWithBackoff, } from '../errors/index.js'; dotenv.config(); const { GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY } = process.env; // Validate configuration at startup if (!GHOST_ADMIN_API_URL || !GHOST_ADMIN_API_KEY) { throw new ConfigurationError( 'Ghost Admin API configuration is incomplete', ['GHOST_ADMIN_API_URL', 'GHOST_ADMIN_API_KEY'].filter((key) => !process.env[key]) ); } // Configure the Ghost Admin API client const api = new GhostAdminAPI({ url: GHOST_ADMIN_API_URL, key: GHOST_ADMIN_API_KEY, version: 'v5.0', }); // Circuit breaker for Ghost API const ghostCircuitBreaker = new CircuitBreaker({ failureThreshold: 5, resetTimeout: 60000, // 1 minute monitoringPeriod: 10000, // 10 seconds }); /** * Enhanced handler for Ghost Admin API requests with proper error handling */ const handleApiRequest = async (resource, action, data = {}, options = {}, config = {}) => { // Validate inputs if (!api[resource] || typeof api[resource][action] !== 'function') { throw new ValidationError(`Invalid Ghost API resource or action: ${resource}.${action}`); } const operation = `${resource}.${action}`; const maxRetries = config.maxRetries ?? 3; const useCircuitBreaker = config.useCircuitBreaker ?? true; // Main execution function const executeRequest = async () => { try { console.error(`Executing Ghost API request: ${operation}`); let result; // Handle different action signatures switch (action) { case 'add': case 'edit': result = await api[resource][action](data, options); break; case 'upload': result = await api[resource][action](data); break; case 'browse': case 'read': result = await api[resource][action](options, data); break; case 'delete': result = await api[resource][action](data.id || data, options); break; default: result = await api[resource][action](data); } console.error(`Successfully executed Ghost API request: ${operation}`); return result; } catch (error) { // Transform Ghost API errors into our error types throw ErrorHandler.fromGhostError(error, operation); } }; // Wrap with circuit breaker if enabled const wrappedExecute = useCircuitBreaker ? () => ghostCircuitBreaker.execute(executeRequest) : executeRequest; // Execute with retry logic try { return await retryWithBackoff(wrappedExecute, { maxAttempts: maxRetries, onRetry: (attempt, _error) => { console.error(`Retrying ${operation} (attempt ${attempt}/${maxRetries})`); // Log circuit breaker state if relevant if (useCircuitBreaker) { const state = ghostCircuitBreaker.getState(); console.error(`Circuit breaker state:`, state); } }, }); } catch (error) { console.error(`Failed to execute ${operation} after ${maxRetries} attempts:`, error.message); throw error; } }; /** * Input validation helpers */ const validators = { validatePostData(postData) { const errors = []; if (!postData.title || postData.title.trim().length === 0) { errors.push({ field: 'title', message: 'Title is required' }); } if (!postData.html && !postData.mobiledoc) { errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' }); } if (postData.status && !['draft', 'published', 'scheduled'].includes(postData.status)) { errors.push({ field: 'status', message: 'Invalid status. Must be draft, published, or scheduled', }); } if (postData.status === 'scheduled' && !postData.published_at) { errors.push({ field: 'published_at', message: 'published_at is required when status is scheduled', }); } if (postData.published_at) { const publishDate = new Date(postData.published_at); if (isNaN(publishDate.getTime())) { errors.push({ field: 'published_at', message: 'Invalid date format' }); } else if (postData.status === 'scheduled' && publishDate <= new Date()) { errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' }); } } if (errors.length > 0) { throw new ValidationError('Post validation failed', errors); } }, validateTagData(tagData) { const errors = []; if (!tagData.name || tagData.name.trim().length === 0) { errors.push({ field: 'name', message: 'Tag name is required' }); } if (tagData.slug && !/^[a-z0-9-]+$/.test(tagData.slug)) { errors.push({ field: 'slug', message: 'Slug must contain only lowercase letters, numbers, and hyphens', }); } if (errors.length > 0) { throw new ValidationError('Tag validation failed', errors); } }, validateTagUpdateData(updateData) { const errors = []; // Name is optional in updates, but if provided, it cannot be empty if (updateData.name !== undefined && updateData.name.trim().length === 0) { errors.push({ field: 'name', message: 'Tag name cannot be empty' }); } // Validate slug format if provided if (updateData.slug && !/^[a-z0-9-]+$/.test(updateData.slug)) { errors.push({ field: 'slug', message: 'Slug must contain only lowercase letters, numbers, and hyphens', }); } if (errors.length > 0) { throw new ValidationError('Tag update validation failed', errors); } }, async validateImagePath(imagePath) { if (!imagePath || typeof imagePath !== 'string') { throw new ValidationError('Image path is required and must be a string'); } // Check if file exists try { await fs.access(imagePath); } catch { throw new NotFoundError('Image file', imagePath); } }, validatePageData(pageData) { const errors = []; if (!pageData.title || pageData.title.trim().length === 0) { errors.push({ field: 'title', message: 'Title is required' }); } if (!pageData.html && !pageData.mobiledoc) { errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' }); } if (pageData.status && !['draft', 'published', 'scheduled'].includes(pageData.status)) { errors.push({ field: 'status', message: 'Invalid status. Must be draft, published, or scheduled', }); } if (pageData.status === 'scheduled' && !pageData.published_at) { errors.push({ field: 'published_at', message: 'published_at is required when status is scheduled', }); } if (pageData.published_at) { const publishDate = new Date(pageData.published_at); if (isNaN(publishDate.getTime())) { errors.push({ field: 'published_at', message: 'Invalid date format' }); } else if (pageData.status === 'scheduled' && publishDate <= new Date()) { errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' }); } } if (errors.length > 0) { throw new ValidationError('Page validation failed', errors); } }, validateNewsletterData(newsletterData) { const errors = []; if (!newsletterData.name || newsletterData.name.trim().length === 0) { errors.push({ field: 'name', message: 'Newsletter name is required' }); } if (errors.length > 0) { throw new ValidationError('Newsletter validation failed', errors); } }, }; /** * Service functions with enhanced error handling */ export async function getSiteInfo() { try { return await handleApiRequest('site', 'read'); } catch (error) { console.error('Failed to get site info:', error); throw error; } } export async function createPost(postData, options = { source: 'html' }) { // Validate input validators.validatePostData(postData); // Add defaults const dataWithDefaults = { status: 'draft', ...postData, }; // Sanitize HTML content if provided if (dataWithDefaults.html) { // Use proper HTML sanitization library to prevent XSS dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, { allowedTags: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'nl', 'li', 'b', 'i', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'span', 'img', 'pre', ], allowedAttributes: { a: ['href', 'title'], img: ['src', 'alt', 'title', 'width', 'height'], '*': ['class', 'id'], }, allowedSchemes: ['http', 'https', 'mailto'], allowedSchemesByTag: { img: ['http', 'https', 'data'], }, }); } try { return await handleApiRequest('posts', 'add', dataWithDefaults, options); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 422) { // Transform Ghost validation errors into our format throw new ValidationError('Post creation failed due to validation errors', [ { field: 'post', message: error.originalError }, ]); } throw error; } } export async function updatePost(postId, updateData, options = {}) { if (!postId) { throw new ValidationError('Post ID is required for update'); } // Get the current post first to ensure it exists try { const existingPost = await handleApiRequest('posts', 'read', { id: postId }); // Merge with existing data const mergedData = { ...existingPost, ...updateData, updated_at: existingPost.updated_at, // Required for Ghost API }; return await handleApiRequest('posts', 'edit', mergedData, { id: postId, ...options }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Post', postId); } throw error; } } export async function deletePost(postId) { if (!postId) { throw new ValidationError('Post ID is required for deletion'); } try { return await handleApiRequest('posts', 'delete', { id: postId }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Post', postId); } throw error; } } export async function getPost(postId, options = {}) { if (!postId) { throw new ValidationError('Post ID is required'); } try { return await handleApiRequest('posts', 'read', { id: postId }, options); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Post', postId); } throw error; } } export async function getPosts(options = {}) { const defaultOptions = { limit: 15, include: 'tags,authors', ...options, }; try { return await handleApiRequest('posts', 'browse', {}, defaultOptions); } catch (error) { console.error('Failed to get posts:', error); throw error; } } export async function searchPosts(query, options = {}) { // Validate query if (!query || query.trim().length === 0) { throw new ValidationError('Search query is required'); } // Sanitize query - escape special NQL characters to prevent injection const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); // Build filter with fuzzy title match using Ghost NQL const filterParts = [`title:~'${sanitizedQuery}'`]; // Add status filter if provided and not 'all' if (options.status && options.status !== 'all') { filterParts.push(`status:${options.status}`); } const searchOptions = { limit: options.limit || 15, include: 'tags,authors', filter: filterParts.join('+'), }; try { return await handleApiRequest('posts', 'browse', {}, searchOptions); } catch (error) { console.error('Failed to search posts:', error); throw error; } } /** * Page CRUD Operations * Pages are similar to posts but do NOT support tags */ export async function createPage(pageData, options = { source: 'html' }) { // Validate input validators.validatePageData(pageData); // Add defaults const dataWithDefaults = { status: 'draft', ...pageData, }; // Sanitize HTML content if provided (use same sanitization as posts) if (dataWithDefaults.html) { dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, { allowedTags: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'nl', 'li', 'b', 'i', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'span', 'img', 'pre', ], allowedAttributes: { a: ['href', 'title'], img: ['src', 'alt', 'title', 'width', 'height'], '*': ['class', 'id'], }, allowedSchemes: ['http', 'https', 'mailto'], allowedSchemesByTag: { img: ['http', 'https', 'data'], }, }); } try { return await handleApiRequest('pages', 'add', dataWithDefaults, options); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 422) { throw new ValidationError('Page creation failed due to validation errors', [ { field: 'page', message: error.originalError }, ]); } throw error; } } export async function updatePage(pageId, updateData, options = {}) { if (!pageId) { throw new ValidationError('Page ID is required for update'); } // Sanitize HTML if being updated if (updateData.html) { updateData.html = sanitizeHtml(updateData.html, { allowedTags: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'nl', 'li', 'b', 'i', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'span', 'img', 'pre', ], allowedAttributes: { a: ['href', 'title'], img: ['src', 'alt', 'title', 'width', 'height'], '*': ['class', 'id'], }, allowedSchemes: ['http', 'https', 'mailto'], allowedSchemesByTag: { img: ['http', 'https', 'data'], }, }); } try { // Get existing page to retrieve updated_at for conflict resolution const existingPage = await handleApiRequest('pages', 'read', { id: pageId }); // Merge existing data with updates, preserving updated_at const mergedData = { ...existingPage, ...updateData, updated_at: existingPage.updated_at, }; return await handleApiRequest('pages', 'edit', mergedData, { id: pageId, ...options }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Page', pageId); } throw error; } } export async function deletePage(pageId) { if (!pageId) { throw new ValidationError('Page ID is required for delete'); } try { return await handleApiRequest('pages', 'delete', { id: pageId }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Page', pageId); } throw error; } } export async function getPage(pageId, options = {}) { if (!pageId) { throw new ValidationError('Page ID is required'); } try { return await handleApiRequest('pages', 'read', { id: pageId }, options); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Page', pageId); } throw error; } } export async function getPages(options = {}) { const defaultOptions = { limit: 15, include: 'authors', ...options, }; try { return await handleApiRequest('pages', 'browse', {}, defaultOptions); } catch (error) { console.error('Failed to get pages:', error); throw error; } } export async function searchPages(query, options = {}) { // Validate query if (!query || query.trim().length === 0) { throw new ValidationError('Search query is required'); } // Sanitize query - escape special NQL characters to prevent injection const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); // Build filter with fuzzy title match using Ghost NQL const filterParts = [`title:~'${sanitizedQuery}'`]; // Add status filter if provided and not 'all' if (options.status && options.status !== 'all') { filterParts.push(`status:${options.status}`); } const searchOptions = { limit: options.limit || 15, include: 'authors', filter: filterParts.join('+'), }; try { return await handleApiRequest('pages', 'browse', {}, searchOptions); } catch (error) { console.error('Failed to search pages:', error); throw error; } } export async function uploadImage(imagePath) { // Validate input await validators.validateImagePath(imagePath); const imageData = { file: imagePath }; try { return await handleApiRequest('images', 'upload', imageData); } catch (error) { if (error instanceof GhostAPIError) { throw new ValidationError(`Image upload failed: ${error.originalError}`); } throw error; } } export async function createTag(tagData) { // Validate input validators.validateTagData(tagData); // Auto-generate slug if not provided if (!tagData.slug) { tagData.slug = tagData.name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } try { return await handleApiRequest('tags', 'add', tagData); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 422) { // Check if it's a duplicate tag error if (error.originalError.includes('already exists')) { // Try to fetch the existing tag const existingTags = await getTags(tagData.name); if (existingTags.length > 0) { return existingTags[0]; // Return existing tag instead of failing } } throw new ValidationError('Tag creation failed', [ { field: 'tag', message: error.originalError }, ]); } throw error; } } export async function getTags(name) { const options = { limit: 'all', ...(name && { filter: `name:'${name}'` }), }; try { const tags = await handleApiRequest('tags', 'browse', {}, options); return tags || []; } catch (error) { console.error('Failed to get tags:', error); throw error; } } export async function getTag(tagId, options = {}) { if (!tagId) { throw new ValidationError('Tag ID is required'); } try { return await handleApiRequest('tags', 'read', { id: tagId }, options); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Tag', tagId); } throw error; } } export async function updateTag(tagId, updateData) { if (!tagId) { throw new ValidationError('Tag ID is required for update'); } validators.validateTagUpdateData(updateData); // Validate update data try { const existingTag = await getTag(tagId); const mergedData = { ...existingTag, ...updateData, }; return await handleApiRequest('tags', 'edit', mergedData, { id: tagId }); } catch (error) { if (error instanceof NotFoundError) { throw error; } if (error instanceof GhostAPIError && error.ghostStatusCode === 422) { throw new ValidationError('Tag update failed', [ { field: 'tag', message: error.originalError }, ]); } throw error; } } export async function deleteTag(tagId) { if (!tagId) { throw new ValidationError('Tag ID is required for deletion'); } try { return await handleApiRequest('tags', 'delete', { id: tagId }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Tag', tagId); } throw error; } } /** * Member CRUD Operations * Members represent subscribers/users in Ghost CMS */ /** * Creates a new member (subscriber) in Ghost CMS * @param {Object} memberData - The member data * @param {string} memberData.email - Member email (required) * @param {string} [memberData.name] - Member name * @param {string} [memberData.note] - Notes about the member (HTML will be sanitized) * @param {string[]} [memberData.labels] - Array of label names * @param {Object[]} [memberData.newsletters] - Array of newsletter objects with id * @param {boolean} [memberData.subscribed] - Email subscription status * @param {Object} [options] - Additional options for the API request * @returns {Promise<Object>} The created member object * @throws {ValidationError} If validation fails * @throws {GhostAPIError} If the API request fails */ export async function createMember(memberData, options = {}) { // Input validation is performed at the MCP tool layer using Zod schemas try { return await handleApiRequest('members', 'add', memberData, options); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 422) { throw new ValidationError('Member creation failed due to validation errors', [ { field: 'member', message: error.originalError }, ]); } throw error; } } /** * Updates an existing member in Ghost CMS * @param {string} memberId - The member ID to update * @param {Object} updateData - The member update data * @param {string} [updateData.email] - Member email * @param {string} [updateData.name] - Member name * @param {string} [updateData.note] - Notes about the member (HTML will be sanitized) * @param {string[]} [updateData.labels] - Array of label names * @param {Object[]} [updateData.newsletters] - Array of newsletter objects with id * @param {boolean} [updateData.subscribed] - Email subscription status * @param {Object} [options] - Additional options for the API request * @returns {Promise<Object>} The updated member object * @throws {ValidationError} If validation fails * @throws {NotFoundError} If the member is not found * @throws {GhostAPIError} If the API request fails */ export async function updateMember(memberId, updateData, options = {}) { // Input validation is performed at the MCP tool layer using Zod schemas if (!memberId) { throw new ValidationError('Member ID is required for update'); } try { // Get existing member to retrieve updated_at for conflict resolution const existingMember = await handleApiRequest('members', 'read', { id: memberId }); // Merge existing data with updates, preserving updated_at const mergedData = { ...existingMember, ...updateData, updated_at: existingMember.updated_at, }; return await handleApiRequest('members', 'edit', mergedData, { id: memberId, ...options }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Member', memberId); } throw error; } } /** * Deletes a member from Ghost CMS * @param {string} memberId - The member ID to delete * @returns {Promise<Object>} Deletion confirmation object * @throws {ValidationError} If member ID is not provided * @throws {NotFoundError} If the member is not found * @throws {GhostAPIError} If the API request fails */ export async function deleteMember(memberId) { if (!memberId) { throw new ValidationError('Member ID is required for deletion'); } try { return await handleApiRequest('members', 'delete', { id: memberId }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Member', memberId); } throw error; } } /** * List members from Ghost CMS with optional filtering and pagination * @param {Object} [options] - Query options * @param {number} [options.limit] - Number of members to return (1-100) * @param {number} [options.page] - Page number (1+) * @param {string} [options.filter] - NQL filter string (e.g., 'status:paid') * @param {string} [options.order] - Order string (e.g., 'created_at desc') * @param {string} [options.include] - Include string (e.g., 'labels,newsletters') * @returns {Promise<Array>} Array of member objects * @throws {ValidationError} If validation fails * @throws {GhostAPIError} If the API request fails */ export async function getMembers(options = {}) { // Input validation is performed at the MCP tool layer using Zod schemas const defaultOptions = { limit: 15, ...options, }; try { const members = await handleApiRequest('members', 'browse', {}, defaultOptions); return members || []; } catch (error) { console.error('Failed to get members:', error); throw error; } } /** * Get a single member from Ghost CMS by ID or email * @param {Object} params - Lookup parameters (id OR email required) * @param {string} [params.id] - Member ID * @param {string} [params.email] - Member email * @returns {Promise<Object>} The member object * @throws {ValidationError} If validation fails * @throws {NotFoundError} If the member is not found * @throws {GhostAPIError} If the API request fails */ export async function getMember(params) { // Input validation is performed at the MCP tool layer using Zod schemas const { sanitizeNqlValue } = await import('./memberService.js'); const { id, email } = params; try { if (id) { // Lookup by ID using read endpoint return await handleApiRequest('members', 'read', { id }, { id }); } else { // Lookup by email using browse with filter const sanitizedEmail = sanitizeNqlValue(email); const members = await handleApiRequest( 'members', 'browse', {}, { filter: `email:'${sanitizedEmail}'`, limit: 1 } ); if (!members || members.length === 0) { throw new NotFoundError('Member', email); } return members[0]; } } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Member', id || email); } throw error; } } /** * Search members by name or email * @param {string} query - Search query (searches name and email fields) * @param {Object} [options] - Additional options * @param {number} [options.limit] - Maximum number of results (default: 15) * @returns {Promise<Array>} Array of matching member objects * @throws {ValidationError} If validation fails * @throws {GhostAPIError} If the API request fails */ export async function searchMembers(query, options = {}) { // Input validation is performed at the MCP tool layer using Zod schemas const { sanitizeNqlValue } = await import('./memberService.js'); const sanitizedQuery = sanitizeNqlValue(query.trim()); const limit = options.limit || 15; // Build NQL filter for name or email containing the query // Ghost uses ~ for contains/like matching const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`; try { const members = await handleApiRequest('members', 'browse', {}, { filter, limit }); return members || []; } catch (error) { console.error('Failed to search members:', error); throw error; } } /** * Newsletter CRUD Operations */ export async function getNewsletters(options = {}) { const defaultOptions = { limit: 'all', ...options, }; try { const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions); return newsletters || []; } catch (error) { console.error('Failed to get newsletters:', error); throw error; } } export async function getNewsletter(newsletterId) { if (!newsletterId) { throw new ValidationError('Newsletter ID is required'); } try { return await handleApiRequest('newsletters', 'read', { id: newsletterId }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Newsletter', newsletterId); } throw error; } } export async function createNewsletter(newsletterData) { // Validate input validators.validateNewsletterData(newsletterData); try { return await handleApiRequest('newsletters', 'add', newsletterData); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 422) { throw new ValidationError('Newsletter creation failed', [ { field: 'newsletter', message: error.originalError }, ]); } throw error; } } export async function updateNewsletter(newsletterId, updateData) { if (!newsletterId) { throw new ValidationError('Newsletter ID is required for update'); } try { // Get existing newsletter to retrieve updated_at for conflict resolution const existingNewsletter = await handleApiRequest('newsletters', 'read', { id: newsletterId, }); // Merge existing data with updates, preserving updated_at const mergedData = { ...existingNewsletter, ...updateData, updated_at: existingNewsletter.updated_at, }; return await handleApiRequest('newsletters', 'edit', mergedData, { id: newsletterId }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Newsletter', newsletterId); } if (error instanceof GhostAPIError && error.ghostStatusCode === 422) { throw new ValidationError('Newsletter update failed', [ { field: 'newsletter', message: error.originalError }, ]); } throw error; } } export async function deleteNewsletter(newsletterId) { if (!newsletterId) { throw new ValidationError('Newsletter ID is required for deletion'); } try { return await handleApiRequest('newsletters', 'delete', newsletterId); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Newsletter', newsletterId); } throw error; } } /** * Create a new tier (membership level) * @param {Object} tierData - Tier data * @param {Object} [options={}] - Options for the API request * @returns {Promise<Object>} Created tier */ export async function createTier(tierData, options = {}) { const { validateTierData } = await import('./tierService.js'); validateTierData(tierData); try { return await handleApiRequest('tiers', 'add', tierData, options); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 422) { throw new ValidationError('Tier creation failed due to validation errors', [ { field: 'tier', message: error.originalError }, ]); } throw error; } } /** * Update an existing tier * @param {string} id - Tier ID * @param {Object} updateData - Tier update data * @param {Object} [options={}] - Options for the API request * @returns {Promise<Object>} Updated tier */ export async function updateTier(id, updateData, options = {}) { if (!id || typeof id !== 'string' || id.trim().length === 0) { throw new ValidationError('Tier ID is required for update'); } const { validateTierUpdateData } = await import('./tierService.js'); validateTierUpdateData(updateData); try { // Get existing tier for merge const existingTier = await handleApiRequest('tiers', 'read', { id }, { id }); // Merge updates with existing data const mergedData = { ...existingTier, ...updateData, updated_at: existingTier.updated_at, }; return await handleApiRequest('tiers', 'edit', mergedData, { id, ...options }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Tier', id); } throw error; } } /** * Delete a tier * @param {string} id - Tier ID * @returns {Promise<Object>} Deletion result */ export async function deleteTier(id) { if (!id || typeof id !== 'string' || id.trim().length === 0) { throw new ValidationError('Tier ID is required for deletion'); } try { return await handleApiRequest('tiers', 'delete', { id }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Tier', id); } throw error; } } /** * Get all tiers with optional filtering * @param {Object} [options={}] - Query options * @param {number} [options.limit] - Number of tiers to return (1-100, default 15) * @param {number} [options.page] - Page number * @param {string} [options.filter] - NQL filter string (e.g., "type:paid", "type:free") * @param {string} [options.order] - Order string * @param {string} [options.include] - Include string * @returns {Promise<Array>} Array of tiers */ export async function getTiers(options = {}) { const { validateTierQueryOptions } = await import('./tierService.js'); validateTierQueryOptions(options); const defaultOptions = { limit: 15, ...options, }; try { const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions); return tiers || []; } catch (error) { console.error('Failed to get tiers:', error); throw error; } } /** * Get a single tier by ID * @param {string} id - Tier ID * @returns {Promise<Object>} Tier object */ export async function getTier(id) { if (!id || typeof id !== 'string' || id.trim().length === 0) { throw new ValidationError('Tier ID is required and must be a non-empty string'); } try { return await handleApiRequest('tiers', 'read', { id }, { id }); } catch (error) { if (error instanceof GhostAPIError && error.ghostStatusCode === 404) { throw new NotFoundError('Tier', id); } throw error; } } /** * Health check for Ghost API connection */ export async function checkHealth() { try { const site = await getSiteInfo(); const circuitState = ghostCircuitBreaker.getState(); return { status: 'healthy', site: { title: site.title, version: site.version, url: site.url, }, circuitBreaker: circuitState, timestamp: new Date().toISOString(), }; } catch (error) { return { status: 'unhealthy', error: error.message, circuitBreaker: ghostCircuitBreaker.getState(), timestamp: new Date().toISOString(), }; } } // Export everything including the API client for backward compatibility export { api, handleApiRequest, ghostCircuitBreaker, validators }; export default { getSiteInfo, createPost, updatePost, deletePost, getPost, getPosts, searchPosts, createPage, updatePage, deletePage, getPage, getPages, searchPages, uploadImage, createTag, getTags, getTag, updateTag, deleteTag, createMember, updateMember, deleteMember, getMembers, getMember, searchMembers, getNewsletters, getNewsletter, createNewsletter, updateNewsletter, deleteNewsletter, createTier, updateTier, deleteTier, getTiers, getTier, checkHealth, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jgardner04/Ghost-MCP-Server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server