Skip to main content
Glama
memberService.js13.3 kB
import sanitizeHtml from 'sanitize-html'; import { ValidationError } from '../errors/index.js'; /** * Email validation regex * Simple but effective email validation */ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** * Maximum length constants (following Ghost's database constraints) */ const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit const MAX_NOTE_LENGTH = 2000; // Reasonable limit for notes const MAX_LABEL_LENGTH = 191; // Label name limit /** * Query constraints for member browsing */ const MIN_LIMIT = 1; const MAX_LIMIT = 100; const MAX_SEARCH_LIMIT = 50; // Lower limit for search operations const MIN_PAGE = 1; /** * Validates member data for creation * @param {Object} memberData - The member data to validate * @throws {ValidationError} If validation fails */ export function validateMemberData(memberData) { const errors = []; // Email is required and must be valid if (!memberData.email || memberData.email.trim().length === 0) { errors.push({ field: 'email', message: 'Email is required' }); } else if (!EMAIL_REGEX.test(memberData.email)) { errors.push({ field: 'email', message: 'Invalid email format' }); } // Name is optional but must be a string with valid length if provided if (memberData.name !== undefined) { if (typeof memberData.name !== 'string') { errors.push({ field: 'name', message: 'Name must be a string' }); } else if (memberData.name.length > MAX_NAME_LENGTH) { errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` }); } } // Note is optional but must be a string with valid length if provided // Sanitize HTML to prevent XSS attacks if (memberData.note !== undefined) { if (typeof memberData.note !== 'string') { errors.push({ field: 'note', message: 'Note must be a string' }); } else { if (memberData.note.length > MAX_NOTE_LENGTH) { errors.push({ field: 'note', message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`, }); } // Sanitize HTML content - strip all HTML tags for notes memberData.note = sanitizeHtml(memberData.note, { allowedTags: [], // Strip all HTML allowedAttributes: {}, }); } } // Labels is optional but must be an array of valid strings if provided if (memberData.labels !== undefined) { if (!Array.isArray(memberData.labels)) { errors.push({ field: 'labels', message: 'Labels must be an array' }); } else { // Validate each label is a non-empty string within length limit const invalidLabels = memberData.labels.filter( (label) => typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH ); if (invalidLabels.length > 0) { errors.push({ field: 'labels', message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`, }); } } } // Newsletters is optional but must be an array of objects with valid id field if (memberData.newsletters !== undefined) { if (!Array.isArray(memberData.newsletters)) { errors.push({ field: 'newsletters', message: 'Newsletters must be an array' }); } else { // Validate each newsletter has a non-empty string id const invalidNewsletters = memberData.newsletters.filter( (newsletter) => !newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0 ); if (invalidNewsletters.length > 0) { errors.push({ field: 'newsletters', message: 'Each newsletter must have a non-empty id field', }); } } } // Subscribed is optional but must be a boolean if provided if (memberData.subscribed !== undefined && typeof memberData.subscribed !== 'boolean') { errors.push({ field: 'subscribed', message: 'Subscribed must be a boolean' }); } if (errors.length > 0) { throw new ValidationError('Member validation failed', errors); } } /** * Validates member data for update * All fields are optional for updates, but if provided they must be valid * @param {Object} updateData - The member update data to validate * @throws {ValidationError} If validation fails */ export function validateMemberUpdateData(updateData) { const errors = []; // Email is optional for update but must be valid if provided if (updateData.email !== undefined) { if (typeof updateData.email !== 'string' || updateData.email.trim().length === 0) { errors.push({ field: 'email', message: 'Email must be a non-empty string' }); } else if (!EMAIL_REGEX.test(updateData.email)) { errors.push({ field: 'email', message: 'Invalid email format' }); } } // Name is optional but must be a string with valid length if provided if (updateData.name !== undefined) { if (typeof updateData.name !== 'string') { errors.push({ field: 'name', message: 'Name must be a string' }); } else if (updateData.name.length > MAX_NAME_LENGTH) { errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` }); } } // Note is optional but must be a string with valid length if provided // Sanitize HTML to prevent XSS attacks if (updateData.note !== undefined) { if (typeof updateData.note !== 'string') { errors.push({ field: 'note', message: 'Note must be a string' }); } else { if (updateData.note.length > MAX_NOTE_LENGTH) { errors.push({ field: 'note', message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`, }); } // Sanitize HTML content - strip all HTML tags for notes updateData.note = sanitizeHtml(updateData.note, { allowedTags: [], // Strip all HTML allowedAttributes: {}, }); } } // Labels is optional but must be an array of valid strings if provided if (updateData.labels !== undefined) { if (!Array.isArray(updateData.labels)) { errors.push({ field: 'labels', message: 'Labels must be an array' }); } else { // Validate each label is a non-empty string within length limit const invalidLabels = updateData.labels.filter( (label) => typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH ); if (invalidLabels.length > 0) { errors.push({ field: 'labels', message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`, }); } } } // Newsletters is optional but must be an array of objects with valid id field if (updateData.newsletters !== undefined) { if (!Array.isArray(updateData.newsletters)) { errors.push({ field: 'newsletters', message: 'Newsletters must be an array' }); } else { // Validate each newsletter has a non-empty string id const invalidNewsletters = updateData.newsletters.filter( (newsletter) => !newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0 ); if (invalidNewsletters.length > 0) { errors.push({ field: 'newsletters', message: 'Each newsletter must have a non-empty id field', }); } } } if (errors.length > 0) { throw new ValidationError('Member validation failed', errors); } } /** * Sanitizes a value for use in NQL filters to prevent injection * Escapes backslashes, single quotes, and double quotes * @param {string} value - The value to sanitize * @returns {string} The sanitized value */ export function sanitizeNqlValue(value) { if (!value) return value; // Escape backslashes first, then quotes return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); } /** * Validates query options for member browsing * @param {Object} options - The query options to validate * @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 * @param {string} [options.order] - Order string (e.g., 'created_at desc') * @param {string} [options.include] - Include string (e.g., 'labels,newsletters') * @throws {ValidationError} If validation fails */ export function validateMemberQueryOptions(options) { const errors = []; // Validate limit if (options.limit !== undefined) { if ( typeof options.limit !== 'number' || options.limit < MIN_LIMIT || options.limit > MAX_LIMIT ) { errors.push({ field: 'limit', message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_LIMIT}`, }); } } // Validate page if (options.page !== undefined) { if (typeof options.page !== 'number' || options.page < MIN_PAGE) { errors.push({ field: 'page', message: `Page must be a number >= ${MIN_PAGE}`, }); } } // Validate filter (must be non-empty string if provided) if (options.filter !== undefined) { if (typeof options.filter !== 'string' || options.filter.trim().length === 0) { errors.push({ field: 'filter', message: 'Filter must be a non-empty string', }); } } // Validate order (must be non-empty string if provided) if (options.order !== undefined) { if (typeof options.order !== 'string' || options.order.trim().length === 0) { errors.push({ field: 'order', message: 'Order must be a non-empty string', }); } } // Validate include (must be non-empty string if provided) if (options.include !== undefined) { if (typeof options.include !== 'string' || options.include.trim().length === 0) { errors.push({ field: 'include', message: 'Include must be a non-empty string', }); } } if (errors.length > 0) { throw new ValidationError('Member query validation failed', errors); } } /** * Validates member lookup parameters (id OR email required) * @param {Object} params - The lookup parameters * @param {string} [params.id] - Member ID * @param {string} [params.email] - Member email * @returns {Object} Normalized params with lookupType ('id' or 'email') * @throws {ValidationError} If validation fails */ export function validateMemberLookup(params) { const errors = []; // Check if id is provided and valid const hasValidId = params.id && typeof params.id === 'string' && params.id.trim().length > 0; // Check if email is provided and valid const hasEmail = params.email !== undefined; const hasValidEmail = hasEmail && typeof params.email === 'string' && EMAIL_REGEX.test(params.email); // Must have at least one valid identifier if (!hasValidId && !hasValidEmail) { if (params.id !== undefined && !hasValidId) { errors.push({ field: 'id', message: 'ID must be a non-empty string' }); } if (hasEmail && !hasValidEmail) { errors.push({ field: 'email', message: 'Invalid email format' }); } if (!params.id && !params.email) { errors.push({ field: 'id|email', message: 'Either id or email is required' }); } throw new ValidationError('Member lookup validation failed', errors); } // Return normalized result - ID takes precedence if both provided if (hasValidId) { return { id: params.id, lookupType: 'id' }; } return { email: params.email, lookupType: 'email' }; } /** * Validates and sanitizes a search query * @param {string} query - The search query * @returns {string} The sanitized query * @throws {ValidationError} If validation fails */ export function validateSearchQuery(query) { const errors = []; if (query === null || query === undefined || typeof query !== 'string') { errors.push({ field: 'query', message: 'Query must be a string' }); throw new ValidationError('Search query validation failed', errors); } const trimmedQuery = query.trim(); if (trimmedQuery.length === 0) { errors.push({ field: 'query', message: 'Query must not be empty' }); throw new ValidationError('Search query validation failed', errors); } return trimmedQuery; } /** * Validates search options (specifically limit for search operations) * Search has a lower max limit (50) than browse operations (100) * @param {Object} options - The search options to validate * @param {number} [options.limit] - Maximum number of results (1-50) * @throws {ValidationError} If validation fails */ export function validateSearchOptions(options) { const errors = []; // Validate limit for search (1-50, lower than browse) if (options.limit !== undefined) { if ( typeof options.limit !== 'number' || options.limit < MIN_LIMIT || options.limit > MAX_SEARCH_LIMIT ) { errors.push({ field: 'limit', message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_SEARCH_LIMIT}`, }); } } if (errors.length > 0) { throw new ValidationError('Search options validation failed', errors); } } export default { validateMemberData, validateMemberUpdateData, validateMemberQueryOptions, validateMemberLookup, validateSearchQuery, validateSearchOptions, sanitizeNqlValue, };

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