@jgardner04/ghost-mcp-server 1.9.0 → 1.11.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/mcp_server_improved.js +300 -2
- package/src/services/__tests__/ghostServiceImproved.members.test.js +288 -1
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +392 -0
- package/src/services/__tests__/memberService.test.js +233 -1
- package/src/services/__tests__/tierService.test.js +372 -0
- package/src/services/ghostServiceImproved.js +238 -0
- package/src/services/memberService.js +190 -0
- package/src/services/tierService.js +304 -0
|
@@ -14,6 +14,14 @@ const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit
|
|
|
14
14
|
const MAX_NOTE_LENGTH = 2000; // Reasonable limit for notes
|
|
15
15
|
const MAX_LABEL_LENGTH = 191; // Label name limit
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Query constraints for member browsing
|
|
19
|
+
*/
|
|
20
|
+
const MIN_LIMIT = 1;
|
|
21
|
+
const MAX_LIMIT = 100;
|
|
22
|
+
const MAX_SEARCH_LIMIT = 50; // Lower limit for search operations
|
|
23
|
+
const MIN_PAGE = 1;
|
|
24
|
+
|
|
17
25
|
/**
|
|
18
26
|
* Validates member data for creation
|
|
19
27
|
* @param {Object} memberData - The member data to validate
|
|
@@ -196,7 +204,189 @@ export function validateMemberUpdateData(updateData) {
|
|
|
196
204
|
}
|
|
197
205
|
}
|
|
198
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Sanitizes a value for use in NQL filters to prevent injection
|
|
209
|
+
* Escapes backslashes, single quotes, and double quotes
|
|
210
|
+
* @param {string} value - The value to sanitize
|
|
211
|
+
* @returns {string} The sanitized value
|
|
212
|
+
*/
|
|
213
|
+
export function sanitizeNqlValue(value) {
|
|
214
|
+
if (!value) return value;
|
|
215
|
+
// Escape backslashes first, then quotes
|
|
216
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validates query options for member browsing
|
|
221
|
+
* @param {Object} options - The query options to validate
|
|
222
|
+
* @param {number} [options.limit] - Number of members to return (1-100)
|
|
223
|
+
* @param {number} [options.page] - Page number (1+)
|
|
224
|
+
* @param {string} [options.filter] - NQL filter string
|
|
225
|
+
* @param {string} [options.order] - Order string (e.g., 'created_at desc')
|
|
226
|
+
* @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
|
|
227
|
+
* @throws {ValidationError} If validation fails
|
|
228
|
+
*/
|
|
229
|
+
export function validateMemberQueryOptions(options) {
|
|
230
|
+
const errors = [];
|
|
231
|
+
|
|
232
|
+
// Validate limit
|
|
233
|
+
if (options.limit !== undefined) {
|
|
234
|
+
if (
|
|
235
|
+
typeof options.limit !== 'number' ||
|
|
236
|
+
options.limit < MIN_LIMIT ||
|
|
237
|
+
options.limit > MAX_LIMIT
|
|
238
|
+
) {
|
|
239
|
+
errors.push({
|
|
240
|
+
field: 'limit',
|
|
241
|
+
message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_LIMIT}`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Validate page
|
|
247
|
+
if (options.page !== undefined) {
|
|
248
|
+
if (typeof options.page !== 'number' || options.page < MIN_PAGE) {
|
|
249
|
+
errors.push({
|
|
250
|
+
field: 'page',
|
|
251
|
+
message: `Page must be a number >= ${MIN_PAGE}`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validate filter (must be non-empty string if provided)
|
|
257
|
+
if (options.filter !== undefined) {
|
|
258
|
+
if (typeof options.filter !== 'string' || options.filter.trim().length === 0) {
|
|
259
|
+
errors.push({
|
|
260
|
+
field: 'filter',
|
|
261
|
+
message: 'Filter must be a non-empty string',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Validate order (must be non-empty string if provided)
|
|
267
|
+
if (options.order !== undefined) {
|
|
268
|
+
if (typeof options.order !== 'string' || options.order.trim().length === 0) {
|
|
269
|
+
errors.push({
|
|
270
|
+
field: 'order',
|
|
271
|
+
message: 'Order must be a non-empty string',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Validate include (must be non-empty string if provided)
|
|
277
|
+
if (options.include !== undefined) {
|
|
278
|
+
if (typeof options.include !== 'string' || options.include.trim().length === 0) {
|
|
279
|
+
errors.push({
|
|
280
|
+
field: 'include',
|
|
281
|
+
message: 'Include must be a non-empty string',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (errors.length > 0) {
|
|
287
|
+
throw new ValidationError('Member query validation failed', errors);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Validates member lookup parameters (id OR email required)
|
|
293
|
+
* @param {Object} params - The lookup parameters
|
|
294
|
+
* @param {string} [params.id] - Member ID
|
|
295
|
+
* @param {string} [params.email] - Member email
|
|
296
|
+
* @returns {Object} Normalized params with lookupType ('id' or 'email')
|
|
297
|
+
* @throws {ValidationError} If validation fails
|
|
298
|
+
*/
|
|
299
|
+
export function validateMemberLookup(params) {
|
|
300
|
+
const errors = [];
|
|
301
|
+
|
|
302
|
+
// Check if id is provided and valid
|
|
303
|
+
const hasValidId = params.id && typeof params.id === 'string' && params.id.trim().length > 0;
|
|
304
|
+
|
|
305
|
+
// Check if email is provided and valid
|
|
306
|
+
const hasEmail = params.email !== undefined;
|
|
307
|
+
const hasValidEmail =
|
|
308
|
+
hasEmail && typeof params.email === 'string' && EMAIL_REGEX.test(params.email);
|
|
309
|
+
|
|
310
|
+
// Must have at least one valid identifier
|
|
311
|
+
if (!hasValidId && !hasValidEmail) {
|
|
312
|
+
if (params.id !== undefined && !hasValidId) {
|
|
313
|
+
errors.push({ field: 'id', message: 'ID must be a non-empty string' });
|
|
314
|
+
}
|
|
315
|
+
if (hasEmail && !hasValidEmail) {
|
|
316
|
+
errors.push({ field: 'email', message: 'Invalid email format' });
|
|
317
|
+
}
|
|
318
|
+
if (!params.id && !params.email) {
|
|
319
|
+
errors.push({ field: 'id|email', message: 'Either id or email is required' });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
throw new ValidationError('Member lookup validation failed', errors);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Return normalized result - ID takes precedence if both provided
|
|
326
|
+
if (hasValidId) {
|
|
327
|
+
return { id: params.id, lookupType: 'id' };
|
|
328
|
+
}
|
|
329
|
+
return { email: params.email, lookupType: 'email' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Validates and sanitizes a search query
|
|
334
|
+
* @param {string} query - The search query
|
|
335
|
+
* @returns {string} The sanitized query
|
|
336
|
+
* @throws {ValidationError} If validation fails
|
|
337
|
+
*/
|
|
338
|
+
export function validateSearchQuery(query) {
|
|
339
|
+
const errors = [];
|
|
340
|
+
|
|
341
|
+
if (query === null || query === undefined || typeof query !== 'string') {
|
|
342
|
+
errors.push({ field: 'query', message: 'Query must be a string' });
|
|
343
|
+
throw new ValidationError('Search query validation failed', errors);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const trimmedQuery = query.trim();
|
|
347
|
+
if (trimmedQuery.length === 0) {
|
|
348
|
+
errors.push({ field: 'query', message: 'Query must not be empty' });
|
|
349
|
+
throw new ValidationError('Search query validation failed', errors);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return trimmedQuery;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Validates search options (specifically limit for search operations)
|
|
357
|
+
* Search has a lower max limit (50) than browse operations (100)
|
|
358
|
+
* @param {Object} options - The search options to validate
|
|
359
|
+
* @param {number} [options.limit] - Maximum number of results (1-50)
|
|
360
|
+
* @throws {ValidationError} If validation fails
|
|
361
|
+
*/
|
|
362
|
+
export function validateSearchOptions(options) {
|
|
363
|
+
const errors = [];
|
|
364
|
+
|
|
365
|
+
// Validate limit for search (1-50, lower than browse)
|
|
366
|
+
if (options.limit !== undefined) {
|
|
367
|
+
if (
|
|
368
|
+
typeof options.limit !== 'number' ||
|
|
369
|
+
options.limit < MIN_LIMIT ||
|
|
370
|
+
options.limit > MAX_SEARCH_LIMIT
|
|
371
|
+
) {
|
|
372
|
+
errors.push({
|
|
373
|
+
field: 'limit',
|
|
374
|
+
message: `Limit must be a number between ${MIN_LIMIT} and ${MAX_SEARCH_LIMIT}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (errors.length > 0) {
|
|
380
|
+
throw new ValidationError('Search options validation failed', errors);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
199
384
|
export default {
|
|
200
385
|
validateMemberData,
|
|
201
386
|
validateMemberUpdateData,
|
|
387
|
+
validateMemberQueryOptions,
|
|
388
|
+
validateMemberLookup,
|
|
389
|
+
validateSearchQuery,
|
|
390
|
+
validateSearchOptions,
|
|
391
|
+
sanitizeNqlValue,
|
|
202
392
|
};
|
|
@@ -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
|
+
};
|