@jgardner04/ghost-mcp-server 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/mcp_server.test.js +10 -4
- package/src/__tests__/mcp_server_improved.test.js +192 -149
- package/src/__tests__/mcp_server_pages.test.js +72 -68
- package/src/errors/__tests__/index.test.js +70 -0
- package/src/errors/index.js +10 -0
- package/src/mcp_server.js +9 -19
- package/src/mcp_server_improved.js +815 -424
- package/src/schemas/__tests__/common.test.js +84 -0
- package/src/schemas/common.js +50 -3
- package/src/services/__tests__/ghostServiceImproved.members.test.js +12 -61
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +392 -0
- package/src/services/__tests__/postService.test.js +7 -99
- package/src/services/__tests__/tierService.test.js +372 -0
- package/src/services/ghostServiceImproved.js +140 -21
- package/src/services/postService.js +4 -30
- package/src/services/tierService.js +304 -0
- package/src/utils/__tests__/tempFileManager.test.js +316 -0
- package/src/utils/__tests__/validation.test.js +163 -0
- package/src/utils/tempFileManager.js +113 -0
- package/src/utils/validation.js +28 -0
|
@@ -792,10 +792,7 @@ export async function deleteTag(tagId) {
|
|
|
792
792
|
* @throws {GhostAPIError} If the API request fails
|
|
793
793
|
*/
|
|
794
794
|
export async function createMember(memberData, options = {}) {
|
|
795
|
-
//
|
|
796
|
-
const { validateMemberData } = await import('./memberService.js');
|
|
797
|
-
validateMemberData(memberData);
|
|
798
|
-
|
|
795
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
799
796
|
try {
|
|
800
797
|
return await handleApiRequest('members', 'add', memberData, options);
|
|
801
798
|
} catch (error) {
|
|
@@ -825,14 +822,11 @@ export async function createMember(memberData, options = {}) {
|
|
|
825
822
|
* @throws {GhostAPIError} If the API request fails
|
|
826
823
|
*/
|
|
827
824
|
export async function updateMember(memberId, updateData, options = {}) {
|
|
825
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
828
826
|
if (!memberId) {
|
|
829
827
|
throw new ValidationError('Member ID is required for update');
|
|
830
828
|
}
|
|
831
829
|
|
|
832
|
-
// Import and validate update data
|
|
833
|
-
const { validateMemberUpdateData } = await import('./memberService.js');
|
|
834
|
-
validateMemberUpdateData(updateData);
|
|
835
|
-
|
|
836
830
|
try {
|
|
837
831
|
// Get existing member to retrieve updated_at for conflict resolution
|
|
838
832
|
const existingMember = await handleApiRequest('members', 'read', { id: memberId });
|
|
@@ -889,10 +883,7 @@ export async function deleteMember(memberId) {
|
|
|
889
883
|
* @throws {GhostAPIError} If the API request fails
|
|
890
884
|
*/
|
|
891
885
|
export async function getMembers(options = {}) {
|
|
892
|
-
//
|
|
893
|
-
const { validateMemberQueryOptions } = await import('./memberService.js');
|
|
894
|
-
validateMemberQueryOptions(options);
|
|
895
|
-
|
|
886
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
896
887
|
const defaultOptions = {
|
|
897
888
|
limit: 15,
|
|
898
889
|
...options,
|
|
@@ -918,12 +909,12 @@ export async function getMembers(options = {}) {
|
|
|
918
909
|
* @throws {GhostAPIError} If the API request fails
|
|
919
910
|
*/
|
|
920
911
|
export async function getMember(params) {
|
|
921
|
-
//
|
|
922
|
-
const {
|
|
923
|
-
const {
|
|
912
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
913
|
+
const { sanitizeNqlValue } = await import('./memberService.js');
|
|
914
|
+
const { id, email } = params;
|
|
924
915
|
|
|
925
916
|
try {
|
|
926
|
-
if (
|
|
917
|
+
if (id) {
|
|
927
918
|
// Lookup by ID using read endpoint
|
|
928
919
|
return await handleApiRequest('members', 'read', { id }, { id });
|
|
929
920
|
} else {
|
|
@@ -960,11 +951,9 @@ export async function getMember(params) {
|
|
|
960
951
|
* @throws {GhostAPIError} If the API request fails
|
|
961
952
|
*/
|
|
962
953
|
export async function searchMembers(query, options = {}) {
|
|
963
|
-
//
|
|
964
|
-
const {
|
|
965
|
-
|
|
966
|
-
validateSearchOptions(options);
|
|
967
|
-
const sanitizedQuery = sanitizeNqlValue(validateSearchQuery(query));
|
|
954
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
955
|
+
const { sanitizeNqlValue } = await import('./memberService.js');
|
|
956
|
+
const sanitizedQuery = sanitizeNqlValue(query.trim());
|
|
968
957
|
|
|
969
958
|
const limit = options.limit || 15;
|
|
970
959
|
|
|
@@ -1078,6 +1067,131 @@ export async function deleteNewsletter(newsletterId) {
|
|
|
1078
1067
|
}
|
|
1079
1068
|
}
|
|
1080
1069
|
|
|
1070
|
+
/**
|
|
1071
|
+
* Create a new tier (membership level)
|
|
1072
|
+
* @param {Object} tierData - Tier data
|
|
1073
|
+
* @param {Object} [options={}] - Options for the API request
|
|
1074
|
+
* @returns {Promise<Object>} Created tier
|
|
1075
|
+
*/
|
|
1076
|
+
export async function createTier(tierData, options = {}) {
|
|
1077
|
+
const { validateTierData } = await import('./tierService.js');
|
|
1078
|
+
validateTierData(tierData);
|
|
1079
|
+
|
|
1080
|
+
try {
|
|
1081
|
+
return await handleApiRequest('tiers', 'add', tierData, options);
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
1084
|
+
throw new ValidationError('Tier creation failed due to validation errors', [
|
|
1085
|
+
{ field: 'tier', message: error.originalError },
|
|
1086
|
+
]);
|
|
1087
|
+
}
|
|
1088
|
+
throw error;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Update an existing tier
|
|
1094
|
+
* @param {string} id - Tier ID
|
|
1095
|
+
* @param {Object} updateData - Tier update data
|
|
1096
|
+
* @param {Object} [options={}] - Options for the API request
|
|
1097
|
+
* @returns {Promise<Object>} Updated tier
|
|
1098
|
+
*/
|
|
1099
|
+
export async function updateTier(id, updateData, options = {}) {
|
|
1100
|
+
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
|
1101
|
+
throw new ValidationError('Tier ID is required for update');
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const { validateTierUpdateData } = await import('./tierService.js');
|
|
1105
|
+
validateTierUpdateData(updateData);
|
|
1106
|
+
|
|
1107
|
+
try {
|
|
1108
|
+
// Get existing tier for merge
|
|
1109
|
+
const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
|
|
1110
|
+
|
|
1111
|
+
// Merge updates with existing data
|
|
1112
|
+
const mergedData = {
|
|
1113
|
+
...existingTier,
|
|
1114
|
+
...updateData,
|
|
1115
|
+
updated_at: existingTier.updated_at,
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
return await handleApiRequest('tiers', 'edit', mergedData, { id, ...options });
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1121
|
+
throw new NotFoundError('Tier', id);
|
|
1122
|
+
}
|
|
1123
|
+
throw error;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Delete a tier
|
|
1129
|
+
* @param {string} id - Tier ID
|
|
1130
|
+
* @returns {Promise<Object>} Deletion result
|
|
1131
|
+
*/
|
|
1132
|
+
export async function deleteTier(id) {
|
|
1133
|
+
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
|
1134
|
+
throw new ValidationError('Tier ID is required for deletion');
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
return await handleApiRequest('tiers', 'delete', { id });
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1141
|
+
throw new NotFoundError('Tier', id);
|
|
1142
|
+
}
|
|
1143
|
+
throw error;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Get all tiers with optional filtering
|
|
1149
|
+
* @param {Object} [options={}] - Query options
|
|
1150
|
+
* @param {number} [options.limit] - Number of tiers to return (1-100, default 15)
|
|
1151
|
+
* @param {number} [options.page] - Page number
|
|
1152
|
+
* @param {string} [options.filter] - NQL filter string (e.g., "type:paid", "type:free")
|
|
1153
|
+
* @param {string} [options.order] - Order string
|
|
1154
|
+
* @param {string} [options.include] - Include string
|
|
1155
|
+
* @returns {Promise<Array>} Array of tiers
|
|
1156
|
+
*/
|
|
1157
|
+
export async function getTiers(options = {}) {
|
|
1158
|
+
const { validateTierQueryOptions } = await import('./tierService.js');
|
|
1159
|
+
validateTierQueryOptions(options);
|
|
1160
|
+
|
|
1161
|
+
const defaultOptions = {
|
|
1162
|
+
limit: 15,
|
|
1163
|
+
...options,
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
try {
|
|
1167
|
+
const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions);
|
|
1168
|
+
return tiers || [];
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
console.error('Failed to get tiers:', error);
|
|
1171
|
+
throw error;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Get a single tier by ID
|
|
1177
|
+
* @param {string} id - Tier ID
|
|
1178
|
+
* @returns {Promise<Object>} Tier object
|
|
1179
|
+
*/
|
|
1180
|
+
export async function getTier(id) {
|
|
1181
|
+
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
|
1182
|
+
throw new ValidationError('Tier ID is required and must be a non-empty string');
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
try {
|
|
1186
|
+
return await handleApiRequest('tiers', 'read', { id }, { id });
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1189
|
+
throw new NotFoundError('Tier', id);
|
|
1190
|
+
}
|
|
1191
|
+
throw error;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1081
1195
|
/**
|
|
1082
1196
|
* Health check for Ghost API connection
|
|
1083
1197
|
*/
|
|
@@ -1140,5 +1254,10 @@ export default {
|
|
|
1140
1254
|
createNewsletter,
|
|
1141
1255
|
updateNewsletter,
|
|
1142
1256
|
deleteNewsletter,
|
|
1257
|
+
createTier,
|
|
1258
|
+
updateTier,
|
|
1259
|
+
deleteTier,
|
|
1260
|
+
getTiers,
|
|
1261
|
+
getTier,
|
|
1143
1262
|
checkHealth,
|
|
1144
1263
|
};
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import sanitizeHtml from 'sanitize-html';
|
|
2
|
-
import Joi from 'joi';
|
|
3
2
|
import { createContextLogger } from '../utils/logger.js';
|
|
4
3
|
import {
|
|
5
4
|
createPost as createGhostPost,
|
|
6
5
|
getTags as getGhostTags,
|
|
7
6
|
createTag as createGhostTag,
|
|
8
|
-
|
|
9
|
-
} from './ghostService.js'; // Note the relative path
|
|
7
|
+
} from './ghostService.js';
|
|
10
8
|
|
|
11
9
|
/**
|
|
12
10
|
* Helper to generate a simple meta description from HTML content.
|
|
@@ -37,37 +35,13 @@ const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => {
|
|
|
37
35
|
/**
|
|
38
36
|
* Service layer function to handle the business logic of creating a post.
|
|
39
37
|
* Transforms input data, handles/resolves tags, includes feature image and metadata.
|
|
40
|
-
*
|
|
38
|
+
* Input validation is handled at the MCP layer using Zod schemas.
|
|
39
|
+
* @param {object} postInput - Validated data received from the MCP tool.
|
|
41
40
|
* @returns {Promise<object>} The created post object from the Ghost API.
|
|
42
41
|
*/
|
|
43
|
-
// Validation schema for post input
|
|
44
|
-
const postInputSchema = Joi.object({
|
|
45
|
-
title: Joi.string().max(255).required(),
|
|
46
|
-
html: Joi.string().required(),
|
|
47
|
-
custom_excerpt: Joi.string().max(500).optional(),
|
|
48
|
-
status: Joi.string().valid('draft', 'published', 'scheduled').optional(),
|
|
49
|
-
published_at: Joi.string().isoDate().optional(),
|
|
50
|
-
tags: Joi.array().items(Joi.string().max(50)).max(10).optional(),
|
|
51
|
-
feature_image: Joi.string().uri().optional(),
|
|
52
|
-
feature_image_alt: Joi.string().max(255).optional(),
|
|
53
|
-
feature_image_caption: Joi.string().max(500).optional(),
|
|
54
|
-
meta_title: Joi.string().max(70).optional(),
|
|
55
|
-
meta_description: Joi.string().max(160).optional(),
|
|
56
|
-
});
|
|
57
|
-
|
|
58
42
|
const createPostService = async (postInput) => {
|
|
59
43
|
const logger = createContextLogger('post-service');
|
|
60
44
|
|
|
61
|
-
// Validate input to prevent format string vulnerabilities
|
|
62
|
-
const { error, value: validatedInput } = postInputSchema.validate(postInput);
|
|
63
|
-
if (error) {
|
|
64
|
-
logger.error('Post input validation failed', {
|
|
65
|
-
error: error.details[0].message,
|
|
66
|
-
inputKeys: Object.keys(postInput),
|
|
67
|
-
});
|
|
68
|
-
throw new Error(`Invalid post input: ${error.details[0].message}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
45
|
const {
|
|
72
46
|
title,
|
|
73
47
|
html,
|
|
@@ -80,7 +54,7 @@ const createPostService = async (postInput) => {
|
|
|
80
54
|
feature_image_caption,
|
|
81
55
|
meta_title,
|
|
82
56
|
meta_description,
|
|
83
|
-
} =
|
|
57
|
+
} = postInput;
|
|
84
58
|
|
|
85
59
|
// --- Resolve Tag Names to Tag Objects (ID/Slug/Name) ---
|
|
86
60
|
let resolvedTags = [];
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { ValidationError } from '../errors/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maximum length constants (following Ghost's database constraints)
|
|
5
|
+
*/
|
|
6
|
+
const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit
|
|
7
|
+
const MAX_DESCRIPTION_LENGTH = 2000; // Reasonable limit for descriptions
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Query constraints for tier browsing
|
|
11
|
+
*/
|
|
12
|
+
const MIN_LIMIT = 1;
|
|
13
|
+
const MAX_LIMIT = 100;
|
|
14
|
+
const MIN_PAGE = 1;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Currency code validation regex (3-letter uppercase)
|
|
18
|
+
*/
|
|
19
|
+
const CURRENCY_REGEX = /^[A-Z]{3}$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* URL validation regex (simple HTTP/HTTPS validation)
|
|
23
|
+
*/
|
|
24
|
+
const URL_REGEX = /^https?:\/\/.+/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validates tier data for creation
|
|
28
|
+
* @param {Object} tierData - The tier data to validate
|
|
29
|
+
* @throws {ValidationError} If validation fails
|
|
30
|
+
*/
|
|
31
|
+
export function validateTierData(tierData) {
|
|
32
|
+
const errors = [];
|
|
33
|
+
|
|
34
|
+
// Name is required and must be a non-empty string
|
|
35
|
+
if (!tierData.name || typeof tierData.name !== 'string' || tierData.name.trim().length === 0) {
|
|
36
|
+
errors.push({ field: 'name', message: 'Name is required and must be a non-empty string' });
|
|
37
|
+
} else if (tierData.name.length > MAX_NAME_LENGTH) {
|
|
38
|
+
errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Currency is required and must be a 3-letter uppercase code
|
|
42
|
+
if (!tierData.currency || typeof tierData.currency !== 'string') {
|
|
43
|
+
errors.push({ field: 'currency', message: 'Currency is required' });
|
|
44
|
+
} else if (!CURRENCY_REGEX.test(tierData.currency)) {
|
|
45
|
+
errors.push({
|
|
46
|
+
field: 'currency',
|
|
47
|
+
message: 'Currency must be a 3-letter uppercase code (e.g., USD, EUR)',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Description is optional but must be a string with valid length if provided
|
|
52
|
+
if (tierData.description !== undefined) {
|
|
53
|
+
if (typeof tierData.description !== 'string') {
|
|
54
|
+
errors.push({ field: 'description', message: 'Description must be a string' });
|
|
55
|
+
} else if (tierData.description.length > MAX_DESCRIPTION_LENGTH) {
|
|
56
|
+
errors.push({
|
|
57
|
+
field: 'description',
|
|
58
|
+
message: `Description must not exceed ${MAX_DESCRIPTION_LENGTH} characters`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Monthly price is optional but must be a non-negative number if provided
|
|
64
|
+
if (tierData.monthly_price !== undefined) {
|
|
65
|
+
if (typeof tierData.monthly_price !== 'number' || tierData.monthly_price < 0) {
|
|
66
|
+
errors.push({
|
|
67
|
+
field: 'monthly_price',
|
|
68
|
+
message: 'Monthly price must be a non-negative number',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Yearly price is optional but must be a non-negative number if provided
|
|
74
|
+
if (tierData.yearly_price !== undefined) {
|
|
75
|
+
if (typeof tierData.yearly_price !== 'number' || tierData.yearly_price < 0) {
|
|
76
|
+
errors.push({
|
|
77
|
+
field: 'yearly_price',
|
|
78
|
+
message: 'Yearly price must be a non-negative number',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Benefits is optional but must be an array of non-empty strings if provided
|
|
84
|
+
if (tierData.benefits !== undefined) {
|
|
85
|
+
if (!Array.isArray(tierData.benefits)) {
|
|
86
|
+
errors.push({ field: 'benefits', message: 'Benefits must be an array' });
|
|
87
|
+
} else {
|
|
88
|
+
// Validate each benefit is a non-empty string
|
|
89
|
+
const invalidBenefits = tierData.benefits.filter(
|
|
90
|
+
(benefit) => typeof benefit !== 'string' || benefit.trim().length === 0
|
|
91
|
+
);
|
|
92
|
+
if (invalidBenefits.length > 0) {
|
|
93
|
+
errors.push({
|
|
94
|
+
field: 'benefits',
|
|
95
|
+
message: 'Each benefit must be a non-empty string',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Welcome page URL is optional but must be a valid HTTP/HTTPS URL if provided
|
|
102
|
+
if (tierData.welcome_page_url !== undefined) {
|
|
103
|
+
if (
|
|
104
|
+
typeof tierData.welcome_page_url !== 'string' ||
|
|
105
|
+
!URL_REGEX.test(tierData.welcome_page_url)
|
|
106
|
+
) {
|
|
107
|
+
errors.push({
|
|
108
|
+
field: 'welcome_page_url',
|
|
109
|
+
message: 'Welcome page URL must be a valid URL',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (errors.length > 0) {
|
|
115
|
+
throw new ValidationError('Tier validation failed', errors);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validates tier data for update
|
|
121
|
+
* All fields are optional for updates, but if provided they must be valid
|
|
122
|
+
* @param {Object} updateData - The tier update data to validate
|
|
123
|
+
* @throws {ValidationError} If validation fails
|
|
124
|
+
*/
|
|
125
|
+
export function validateTierUpdateData(updateData) {
|
|
126
|
+
const errors = [];
|
|
127
|
+
|
|
128
|
+
// Name is optional for update but must be a non-empty string with valid length if provided
|
|
129
|
+
if (updateData.name !== undefined) {
|
|
130
|
+
if (typeof updateData.name !== 'string' || updateData.name.trim().length === 0) {
|
|
131
|
+
errors.push({ field: 'name', message: 'Name must be a non-empty string' });
|
|
132
|
+
} else if (updateData.name.length > MAX_NAME_LENGTH) {
|
|
133
|
+
errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Currency is optional for update but must be a 3-letter uppercase code if provided
|
|
138
|
+
if (updateData.currency !== undefined) {
|
|
139
|
+
if (typeof updateData.currency !== 'string' || !CURRENCY_REGEX.test(updateData.currency)) {
|
|
140
|
+
errors.push({
|
|
141
|
+
field: 'currency',
|
|
142
|
+
message: 'Currency must be a 3-letter uppercase code (e.g., USD, EUR)',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Description is optional but must be a string with valid length if provided
|
|
148
|
+
if (updateData.description !== undefined) {
|
|
149
|
+
if (typeof updateData.description !== 'string') {
|
|
150
|
+
errors.push({ field: 'description', message: 'Description must be a string' });
|
|
151
|
+
} else if (updateData.description.length > MAX_DESCRIPTION_LENGTH) {
|
|
152
|
+
errors.push({
|
|
153
|
+
field: 'description',
|
|
154
|
+
message: `Description must not exceed ${MAX_DESCRIPTION_LENGTH} characters`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Monthly price is optional but must be a non-negative number if provided
|
|
160
|
+
if (updateData.monthly_price !== undefined) {
|
|
161
|
+
if (typeof updateData.monthly_price !== 'number' || updateData.monthly_price < 0) {
|
|
162
|
+
errors.push({
|
|
163
|
+
field: 'monthly_price',
|
|
164
|
+
message: 'Monthly price must be a non-negative number',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Yearly price is optional but must be a non-negative number if provided
|
|
170
|
+
if (updateData.yearly_price !== undefined) {
|
|
171
|
+
if (typeof updateData.yearly_price !== 'number' || updateData.yearly_price < 0) {
|
|
172
|
+
errors.push({
|
|
173
|
+
field: 'yearly_price',
|
|
174
|
+
message: 'Yearly price must be a non-negative number',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Benefits is optional but must be an array of non-empty strings if provided
|
|
180
|
+
if (updateData.benefits !== undefined) {
|
|
181
|
+
if (!Array.isArray(updateData.benefits)) {
|
|
182
|
+
errors.push({ field: 'benefits', message: 'Benefits must be an array' });
|
|
183
|
+
} else {
|
|
184
|
+
// Validate each benefit is a non-empty string
|
|
185
|
+
const invalidBenefits = updateData.benefits.filter(
|
|
186
|
+
(benefit) => typeof benefit !== 'string' || benefit.trim().length === 0
|
|
187
|
+
);
|
|
188
|
+
if (invalidBenefits.length > 0) {
|
|
189
|
+
errors.push({
|
|
190
|
+
field: 'benefits',
|
|
191
|
+
message: 'Each benefit must be a non-empty string',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Welcome page URL is optional but must be a valid HTTP/HTTPS URL if provided
|
|
198
|
+
if (updateData.welcome_page_url !== undefined) {
|
|
199
|
+
if (
|
|
200
|
+
typeof updateData.welcome_page_url !== 'string' ||
|
|
201
|
+
!URL_REGEX.test(updateData.welcome_page_url)
|
|
202
|
+
) {
|
|
203
|
+
errors.push({
|
|
204
|
+
field: 'welcome_page_url',
|
|
205
|
+
message: 'Welcome page URL must be a valid URL',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (errors.length > 0) {
|
|
211
|
+
throw new ValidationError('Tier validation failed', errors);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Validates query options for tier browsing
|
|
217
|
+
* @param {Object} options - The query options to validate
|
|
218
|
+
* @param {number} [options.limit] - Number of tiers to return (1-100)
|
|
219
|
+
* @param {number} [options.page] - Page number (1+)
|
|
220
|
+
* @param {string} [options.filter] - NQL filter string
|
|
221
|
+
* @param {string} [options.order] - Order string (e.g., 'created_at desc')
|
|
222
|
+
* @param {string} [options.include] - Include string (e.g., 'monthly_price,yearly_price')
|
|
223
|
+
* @throws {ValidationError} If validation fails
|
|
224
|
+
*/
|
|
225
|
+
export function validateTierQueryOptions(options) {
|
|
226
|
+
const errors = [];
|
|
227
|
+
|
|
228
|
+
// Validate limit
|
|
229
|
+
if (options.limit !== undefined) {
|
|
230
|
+
if (
|
|
231
|
+
typeof options.limit !== 'number' ||
|
|
232
|
+
options.limit < MIN_LIMIT ||
|
|
233
|
+
options.limit > MAX_LIMIT
|
|
234
|
+
) {
|
|
235
|
+
errors.push({
|
|
236
|
+
field: 'limit',
|
|
237
|
+
message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_LIMIT}`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Validate page
|
|
243
|
+
if (options.page !== undefined) {
|
|
244
|
+
if (typeof options.page !== 'number' || options.page < MIN_PAGE) {
|
|
245
|
+
errors.push({
|
|
246
|
+
field: 'page',
|
|
247
|
+
message: `Page must be a number >= ${MIN_PAGE}`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Validate filter (must be non-empty string if provided)
|
|
253
|
+
if (options.filter !== undefined) {
|
|
254
|
+
if (typeof options.filter !== 'string' || options.filter.trim().length === 0) {
|
|
255
|
+
errors.push({
|
|
256
|
+
field: 'filter',
|
|
257
|
+
message: 'Filter must be a non-empty string',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Validate order (must be non-empty string if provided)
|
|
263
|
+
if (options.order !== undefined) {
|
|
264
|
+
if (typeof options.order !== 'string' || options.order.trim().length === 0) {
|
|
265
|
+
errors.push({
|
|
266
|
+
field: 'order',
|
|
267
|
+
message: 'Order must be a non-empty string',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Validate include (must be non-empty string if provided)
|
|
273
|
+
if (options.include !== undefined) {
|
|
274
|
+
if (typeof options.include !== 'string' || options.include.trim().length === 0) {
|
|
275
|
+
errors.push({
|
|
276
|
+
field: 'include',
|
|
277
|
+
message: 'Include must be a non-empty string',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (errors.length > 0) {
|
|
283
|
+
throw new ValidationError('Tier query validation failed', errors);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Sanitizes a value for use in NQL filters to prevent injection
|
|
289
|
+
* Escapes backslashes, single quotes, and double quotes
|
|
290
|
+
* @param {string} value - The value to sanitize
|
|
291
|
+
* @returns {string} The sanitized value
|
|
292
|
+
*/
|
|
293
|
+
export function sanitizeNqlValue(value) {
|
|
294
|
+
if (!value) return value;
|
|
295
|
+
// Escape backslashes first, then quotes
|
|
296
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export default {
|
|
300
|
+
validateTierData,
|
|
301
|
+
validateTierUpdateData,
|
|
302
|
+
validateTierQueryOptions,
|
|
303
|
+
sanitizeNqlValue,
|
|
304
|
+
};
|