@jgardner04/ghost-mcp-server 1.8.0 → 1.10.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.
@@ -772,6 +772,215 @@ export async function deleteTag(tagId) {
772
772
  }
773
773
  }
774
774
 
775
+ /**
776
+ * Member CRUD Operations
777
+ * Members represent subscribers/users in Ghost CMS
778
+ */
779
+
780
+ /**
781
+ * Creates a new member (subscriber) in Ghost CMS
782
+ * @param {Object} memberData - The member data
783
+ * @param {string} memberData.email - Member email (required)
784
+ * @param {string} [memberData.name] - Member name
785
+ * @param {string} [memberData.note] - Notes about the member (HTML will be sanitized)
786
+ * @param {string[]} [memberData.labels] - Array of label names
787
+ * @param {Object[]} [memberData.newsletters] - Array of newsletter objects with id
788
+ * @param {boolean} [memberData.subscribed] - Email subscription status
789
+ * @param {Object} [options] - Additional options for the API request
790
+ * @returns {Promise<Object>} The created member object
791
+ * @throws {ValidationError} If validation fails
792
+ * @throws {GhostAPIError} If the API request fails
793
+ */
794
+ export async function createMember(memberData, options = {}) {
795
+ // Import and validate input
796
+ const { validateMemberData } = await import('./memberService.js');
797
+ validateMemberData(memberData);
798
+
799
+ try {
800
+ return await handleApiRequest('members', 'add', memberData, options);
801
+ } catch (error) {
802
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
803
+ throw new ValidationError('Member creation failed due to validation errors', [
804
+ { field: 'member', message: error.originalError },
805
+ ]);
806
+ }
807
+ throw error;
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Updates an existing member in Ghost CMS
813
+ * @param {string} memberId - The member ID to update
814
+ * @param {Object} updateData - The member update data
815
+ * @param {string} [updateData.email] - Member email
816
+ * @param {string} [updateData.name] - Member name
817
+ * @param {string} [updateData.note] - Notes about the member (HTML will be sanitized)
818
+ * @param {string[]} [updateData.labels] - Array of label names
819
+ * @param {Object[]} [updateData.newsletters] - Array of newsletter objects with id
820
+ * @param {boolean} [updateData.subscribed] - Email subscription status
821
+ * @param {Object} [options] - Additional options for the API request
822
+ * @returns {Promise<Object>} The updated member object
823
+ * @throws {ValidationError} If validation fails
824
+ * @throws {NotFoundError} If the member is not found
825
+ * @throws {GhostAPIError} If the API request fails
826
+ */
827
+ export async function updateMember(memberId, updateData, options = {}) {
828
+ if (!memberId) {
829
+ throw new ValidationError('Member ID is required for update');
830
+ }
831
+
832
+ // Import and validate update data
833
+ const { validateMemberUpdateData } = await import('./memberService.js');
834
+ validateMemberUpdateData(updateData);
835
+
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
+ }
854
+ }
855
+
856
+ /**
857
+ * Deletes a member from Ghost CMS
858
+ * @param {string} memberId - The member ID to delete
859
+ * @returns {Promise<Object>} Deletion confirmation object
860
+ * @throws {ValidationError} If member ID is not provided
861
+ * @throws {NotFoundError} If the member is not found
862
+ * @throws {GhostAPIError} If the API request fails
863
+ */
864
+ 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
+ }
877
+ }
878
+
879
+ /**
880
+ * List members from Ghost CMS with optional filtering and pagination
881
+ * @param {Object} [options] - Query options
882
+ * @param {number} [options.limit] - Number of members to return (1-100)
883
+ * @param {number} [options.page] - Page number (1+)
884
+ * @param {string} [options.filter] - NQL filter string (e.g., 'status:paid')
885
+ * @param {string} [options.order] - Order string (e.g., 'created_at desc')
886
+ * @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
887
+ * @returns {Promise<Array>} Array of member objects
888
+ * @throws {ValidationError} If validation fails
889
+ * @throws {GhostAPIError} If the API request fails
890
+ */
891
+ export async function getMembers(options = {}) {
892
+ // Import and validate query options
893
+ const { validateMemberQueryOptions } = await import('./memberService.js');
894
+ validateMemberQueryOptions(options);
895
+
896
+ const defaultOptions = {
897
+ limit: 15,
898
+ ...options,
899
+ };
900
+
901
+ try {
902
+ const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
903
+ return members || [];
904
+ } catch (error) {
905
+ console.error('Failed to get members:', error);
906
+ throw error;
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Get a single member from Ghost CMS by ID or email
912
+ * @param {Object} params - Lookup parameters (id OR email required)
913
+ * @param {string} [params.id] - Member ID
914
+ * @param {string} [params.email] - Member email
915
+ * @returns {Promise<Object>} The member object
916
+ * @throws {ValidationError} If validation fails
917
+ * @throws {NotFoundError} If the member is not found
918
+ * @throws {GhostAPIError} If the API request fails
919
+ */
920
+ export async function getMember(params) {
921
+ // Import and validate lookup parameters
922
+ const { validateMemberLookup, sanitizeNqlValue } = await import('./memberService.js');
923
+ const { lookupType, id, email } = validateMemberLookup(params);
924
+
925
+ try {
926
+ if (lookupType === 'id') {
927
+ // Lookup by ID using read endpoint
928
+ return await handleApiRequest('members', 'read', { id }, { id });
929
+ } else {
930
+ // Lookup by email using browse with filter
931
+ const sanitizedEmail = sanitizeNqlValue(email);
932
+ const members = await handleApiRequest(
933
+ 'members',
934
+ 'browse',
935
+ {},
936
+ { filter: `email:'${sanitizedEmail}'`, limit: 1 }
937
+ );
938
+
939
+ if (!members || members.length === 0) {
940
+ throw new NotFoundError('Member', email);
941
+ }
942
+
943
+ return members[0];
944
+ }
945
+ } catch (error) {
946
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
947
+ throw new NotFoundError('Member', id || email);
948
+ }
949
+ throw error;
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Search members by name or email
955
+ * @param {string} query - Search query (searches name and email fields)
956
+ * @param {Object} [options] - Additional options
957
+ * @param {number} [options.limit] - Maximum number of results (default: 15)
958
+ * @returns {Promise<Array>} Array of matching member objects
959
+ * @throws {ValidationError} If validation fails
960
+ * @throws {GhostAPIError} If the API request fails
961
+ */
962
+ export async function searchMembers(query, options = {}) {
963
+ // Import and validate search query and options
964
+ const { validateSearchQuery, validateSearchOptions, sanitizeNqlValue } =
965
+ await import('./memberService.js');
966
+ validateSearchOptions(options);
967
+ const sanitizedQuery = sanitizeNqlValue(validateSearchQuery(query));
968
+
969
+ const limit = options.limit || 15;
970
+
971
+ // Build NQL filter for name or email containing the query
972
+ // Ghost uses ~ for contains/like matching
973
+ const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
974
+
975
+ try {
976
+ const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
977
+ return members || [];
978
+ } catch (error) {
979
+ console.error('Failed to search members:', error);
980
+ throw error;
981
+ }
982
+ }
983
+
775
984
  /**
776
985
  * Newsletter CRUD Operations
777
986
  */
@@ -920,6 +1129,12 @@ export default {
920
1129
  getTag,
921
1130
  updateTag,
922
1131
  deleteTag,
1132
+ createMember,
1133
+ updateMember,
1134
+ deleteMember,
1135
+ getMembers,
1136
+ getMember,
1137
+ searchMembers,
923
1138
  getNewsletters,
924
1139
  getNewsletter,
925
1140
  createNewsletter,
@@ -0,0 +1,392 @@
1
+ import sanitizeHtml from 'sanitize-html';
2
+ import { ValidationError } from '../errors/index.js';
3
+
4
+ /**
5
+ * Email validation regex
6
+ * Simple but effective email validation
7
+ */
8
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
9
+
10
+ /**
11
+ * Maximum length constants (following Ghost's database constraints)
12
+ */
13
+ const MAX_NAME_LENGTH = 191; // Ghost's typical varchar limit
14
+ const MAX_NOTE_LENGTH = 2000; // Reasonable limit for notes
15
+ const MAX_LABEL_LENGTH = 191; // Label name limit
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
+
25
+ /**
26
+ * Validates member data for creation
27
+ * @param {Object} memberData - The member data to validate
28
+ * @throws {ValidationError} If validation fails
29
+ */
30
+ export function validateMemberData(memberData) {
31
+ const errors = [];
32
+
33
+ // Email is required and must be valid
34
+ if (!memberData.email || memberData.email.trim().length === 0) {
35
+ errors.push({ field: 'email', message: 'Email is required' });
36
+ } else if (!EMAIL_REGEX.test(memberData.email)) {
37
+ errors.push({ field: 'email', message: 'Invalid email format' });
38
+ }
39
+
40
+ // Name is optional but must be a string with valid length if provided
41
+ if (memberData.name !== undefined) {
42
+ if (typeof memberData.name !== 'string') {
43
+ errors.push({ field: 'name', message: 'Name must be a string' });
44
+ } else if (memberData.name.length > MAX_NAME_LENGTH) {
45
+ errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
46
+ }
47
+ }
48
+
49
+ // Note is optional but must be a string with valid length if provided
50
+ // Sanitize HTML to prevent XSS attacks
51
+ if (memberData.note !== undefined) {
52
+ if (typeof memberData.note !== 'string') {
53
+ errors.push({ field: 'note', message: 'Note must be a string' });
54
+ } else {
55
+ if (memberData.note.length > MAX_NOTE_LENGTH) {
56
+ errors.push({
57
+ field: 'note',
58
+ message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`,
59
+ });
60
+ }
61
+ // Sanitize HTML content - strip all HTML tags for notes
62
+ memberData.note = sanitizeHtml(memberData.note, {
63
+ allowedTags: [], // Strip all HTML
64
+ allowedAttributes: {},
65
+ });
66
+ }
67
+ }
68
+
69
+ // Labels is optional but must be an array of valid strings if provided
70
+ if (memberData.labels !== undefined) {
71
+ if (!Array.isArray(memberData.labels)) {
72
+ errors.push({ field: 'labels', message: 'Labels must be an array' });
73
+ } else {
74
+ // Validate each label is a non-empty string within length limit
75
+ const invalidLabels = memberData.labels.filter(
76
+ (label) =>
77
+ typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH
78
+ );
79
+ if (invalidLabels.length > 0) {
80
+ errors.push({
81
+ field: 'labels',
82
+ message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`,
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ // Newsletters is optional but must be an array of objects with valid id field
89
+ if (memberData.newsletters !== undefined) {
90
+ if (!Array.isArray(memberData.newsletters)) {
91
+ errors.push({ field: 'newsletters', message: 'Newsletters must be an array' });
92
+ } else {
93
+ // Validate each newsletter has a non-empty string id
94
+ const invalidNewsletters = memberData.newsletters.filter(
95
+ (newsletter) =>
96
+ !newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0
97
+ );
98
+ if (invalidNewsletters.length > 0) {
99
+ errors.push({
100
+ field: 'newsletters',
101
+ message: 'Each newsletter must have a non-empty id field',
102
+ });
103
+ }
104
+ }
105
+ }
106
+
107
+ // Subscribed is optional but must be a boolean if provided
108
+ if (memberData.subscribed !== undefined && typeof memberData.subscribed !== 'boolean') {
109
+ errors.push({ field: 'subscribed', message: 'Subscribed must be a boolean' });
110
+ }
111
+
112
+ if (errors.length > 0) {
113
+ throw new ValidationError('Member validation failed', errors);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Validates member data for update
119
+ * All fields are optional for updates, but if provided they must be valid
120
+ * @param {Object} updateData - The member update data to validate
121
+ * @throws {ValidationError} If validation fails
122
+ */
123
+ export function validateMemberUpdateData(updateData) {
124
+ const errors = [];
125
+
126
+ // Email is optional for update but must be valid if provided
127
+ if (updateData.email !== undefined) {
128
+ if (typeof updateData.email !== 'string' || updateData.email.trim().length === 0) {
129
+ errors.push({ field: 'email', message: 'Email must be a non-empty string' });
130
+ } else if (!EMAIL_REGEX.test(updateData.email)) {
131
+ errors.push({ field: 'email', message: 'Invalid email format' });
132
+ }
133
+ }
134
+
135
+ // Name is optional but must be a string with valid length if provided
136
+ if (updateData.name !== undefined) {
137
+ if (typeof updateData.name !== 'string') {
138
+ errors.push({ field: 'name', message: 'Name must be a string' });
139
+ } else if (updateData.name.length > MAX_NAME_LENGTH) {
140
+ errors.push({ field: 'name', message: `Name must not exceed ${MAX_NAME_LENGTH} characters` });
141
+ }
142
+ }
143
+
144
+ // Note is optional but must be a string with valid length if provided
145
+ // Sanitize HTML to prevent XSS attacks
146
+ if (updateData.note !== undefined) {
147
+ if (typeof updateData.note !== 'string') {
148
+ errors.push({ field: 'note', message: 'Note must be a string' });
149
+ } else {
150
+ if (updateData.note.length > MAX_NOTE_LENGTH) {
151
+ errors.push({
152
+ field: 'note',
153
+ message: `Note must not exceed ${MAX_NOTE_LENGTH} characters`,
154
+ });
155
+ }
156
+ // Sanitize HTML content - strip all HTML tags for notes
157
+ updateData.note = sanitizeHtml(updateData.note, {
158
+ allowedTags: [], // Strip all HTML
159
+ allowedAttributes: {},
160
+ });
161
+ }
162
+ }
163
+
164
+ // Labels is optional but must be an array of valid strings if provided
165
+ if (updateData.labels !== undefined) {
166
+ if (!Array.isArray(updateData.labels)) {
167
+ errors.push({ field: 'labels', message: 'Labels must be an array' });
168
+ } else {
169
+ // Validate each label is a non-empty string within length limit
170
+ const invalidLabels = updateData.labels.filter(
171
+ (label) =>
172
+ typeof label !== 'string' || label.trim().length === 0 || label.length > MAX_LABEL_LENGTH
173
+ );
174
+ if (invalidLabels.length > 0) {
175
+ errors.push({
176
+ field: 'labels',
177
+ message: `Each label must be a non-empty string (max ${MAX_LABEL_LENGTH} characters)`,
178
+ });
179
+ }
180
+ }
181
+ }
182
+
183
+ // Newsletters is optional but must be an array of objects with valid id field
184
+ if (updateData.newsletters !== undefined) {
185
+ if (!Array.isArray(updateData.newsletters)) {
186
+ errors.push({ field: 'newsletters', message: 'Newsletters must be an array' });
187
+ } else {
188
+ // Validate each newsletter has a non-empty string id
189
+ const invalidNewsletters = updateData.newsletters.filter(
190
+ (newsletter) =>
191
+ !newsletter.id || typeof newsletter.id !== 'string' || newsletter.id.trim().length === 0
192
+ );
193
+ if (invalidNewsletters.length > 0) {
194
+ errors.push({
195
+ field: 'newsletters',
196
+ message: 'Each newsletter must have a non-empty id field',
197
+ });
198
+ }
199
+ }
200
+ }
201
+
202
+ if (errors.length > 0) {
203
+ throw new ValidationError('Member validation failed', errors);
204
+ }
205
+ }
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
+
384
+ export default {
385
+ validateMemberData,
386
+ validateMemberUpdateData,
387
+ validateMemberQueryOptions,
388
+ validateMemberLookup,
389
+ validateSearchQuery,
390
+ validateSearchOptions,
391
+ sanitizeNqlValue,
392
+ };