@jgardner04/ghost-mcp-server 1.6.0 → 1.8.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.
@@ -0,0 +1,262 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ createTagSchema,
4
+ updateTagSchema,
5
+ tagQuerySchema,
6
+ tagIdSchema,
7
+ tagOutputSchema,
8
+ } from '../tagSchemas.js';
9
+
10
+ describe('Tag Schemas', () => {
11
+ describe('createTagSchema', () => {
12
+ it('should accept valid tag creation data', () => {
13
+ const validTag = {
14
+ name: 'Technology',
15
+ slug: 'technology',
16
+ description: 'Posts about technology',
17
+ visibility: 'public',
18
+ };
19
+
20
+ expect(() => createTagSchema.parse(validTag)).not.toThrow();
21
+ });
22
+
23
+ it('should accept minimal tag creation data (name only)', () => {
24
+ const minimalTag = {
25
+ name: 'News',
26
+ };
27
+
28
+ const result = createTagSchema.parse(minimalTag);
29
+ expect(result.name).toBe('News');
30
+ expect(result.visibility).toBe('public'); // default value
31
+ });
32
+
33
+ it('should accept tag with all optional fields', () => {
34
+ const fullTag = {
35
+ name: 'Technology',
36
+ slug: 'tech',
37
+ description: 'Tech posts',
38
+ feature_image: 'https://example.com/image.jpg',
39
+ visibility: 'public',
40
+ meta_title: 'Technology Posts',
41
+ meta_description: 'All about tech',
42
+ og_image: 'https://example.com/og.jpg',
43
+ og_title: 'Tech on Our Blog',
44
+ og_description: 'Technology articles',
45
+ twitter_image: 'https://example.com/twitter.jpg',
46
+ twitter_title: 'Tech Posts',
47
+ twitter_description: 'Latest tech news',
48
+ codeinjection_head: '<script>console.log("head")</script>',
49
+ codeinjection_foot: '<script>console.log("foot")</script>',
50
+ canonical_url: 'https://example.com/tech',
51
+ accent_color: '#FF5733',
52
+ };
53
+
54
+ expect(() => createTagSchema.parse(fullTag)).not.toThrow();
55
+ });
56
+
57
+ it('should reject tag without name', () => {
58
+ const invalidTag = {
59
+ slug: 'tech',
60
+ };
61
+
62
+ expect(() => createTagSchema.parse(invalidTag)).toThrow();
63
+ });
64
+
65
+ it('should reject tag with invalid slug', () => {
66
+ const invalidTag = {
67
+ name: 'Technology',
68
+ slug: 'Tech_Posts', // uppercase and underscore not allowed
69
+ };
70
+
71
+ expect(() => createTagSchema.parse(invalidTag)).toThrow();
72
+ });
73
+
74
+ it('should reject tag with invalid accent color', () => {
75
+ const invalidTag = {
76
+ name: 'Technology',
77
+ accent_color: 'red', // must be hex format
78
+ };
79
+
80
+ expect(() => createTagSchema.parse(invalidTag)).toThrow();
81
+ });
82
+
83
+ it('should reject tag with too long description', () => {
84
+ const invalidTag = {
85
+ name: 'Technology',
86
+ description: 'A'.repeat(501),
87
+ };
88
+
89
+ expect(() => createTagSchema.parse(invalidTag)).toThrow();
90
+ });
91
+
92
+ it('should reject tag with invalid visibility', () => {
93
+ const invalidTag = {
94
+ name: 'Technology',
95
+ visibility: 'private', // not a valid value
96
+ };
97
+
98
+ expect(() => createTagSchema.parse(invalidTag)).toThrow();
99
+ });
100
+ });
101
+
102
+ describe('updateTagSchema', () => {
103
+ it('should accept partial tag updates', () => {
104
+ const update = {
105
+ description: 'Updated description',
106
+ };
107
+
108
+ expect(() => updateTagSchema.parse(update)).not.toThrow();
109
+ });
110
+
111
+ it('should accept empty update object', () => {
112
+ expect(() => updateTagSchema.parse({})).not.toThrow();
113
+ });
114
+
115
+ it('should accept full tag update', () => {
116
+ const update = {
117
+ name: 'Updated Name',
118
+ slug: 'updated-slug',
119
+ description: 'Updated description',
120
+ };
121
+
122
+ expect(() => updateTagSchema.parse(update)).not.toThrow();
123
+ });
124
+ });
125
+
126
+ describe('tagQuerySchema', () => {
127
+ it('should accept valid query parameters', () => {
128
+ const query = {
129
+ name: 'Technology',
130
+ limit: 20,
131
+ page: 2,
132
+ };
133
+
134
+ expect(() => tagQuerySchema.parse(query)).not.toThrow();
135
+ });
136
+
137
+ it('should accept query with NQL filter', () => {
138
+ const query = {
139
+ filter: 'visibility:public+featured:true',
140
+ limit: 10,
141
+ };
142
+
143
+ expect(() => tagQuerySchema.parse(query)).not.toThrow();
144
+ });
145
+
146
+ it('should accept query with include parameter', () => {
147
+ const query = {
148
+ include: 'count.posts',
149
+ };
150
+
151
+ expect(() => tagQuerySchema.parse(query)).not.toThrow();
152
+ });
153
+
154
+ it('should accept query with order parameter', () => {
155
+ const query = {
156
+ order: 'name ASC',
157
+ };
158
+
159
+ expect(() => tagQuerySchema.parse(query)).not.toThrow();
160
+ });
161
+
162
+ it('should reject query with invalid filter characters', () => {
163
+ const query = {
164
+ filter: 'status;DROP TABLE',
165
+ };
166
+
167
+ expect(() => tagQuerySchema.parse(query)).toThrow();
168
+ });
169
+
170
+ it('should accept empty query object', () => {
171
+ const result = tagQuerySchema.parse({});
172
+ expect(result).toBeDefined();
173
+ // Note: optional fields with defaults don't apply when field is omitted
174
+ });
175
+ });
176
+
177
+ describe('tagIdSchema', () => {
178
+ it('should accept valid Ghost ID', () => {
179
+ const validId = {
180
+ id: '507f1f77bcf86cd799439011',
181
+ };
182
+
183
+ expect(() => tagIdSchema.parse(validId)).not.toThrow();
184
+ });
185
+
186
+ it('should reject invalid Ghost ID', () => {
187
+ const invalidId = {
188
+ id: 'invalid-id',
189
+ };
190
+
191
+ expect(() => tagIdSchema.parse(invalidId)).toThrow();
192
+ });
193
+ });
194
+
195
+ describe('tagOutputSchema', () => {
196
+ it('should accept valid tag output from Ghost API', () => {
197
+ const apiTag = {
198
+ id: '507f1f77bcf86cd799439011',
199
+ name: 'Technology',
200
+ slug: 'technology',
201
+ description: 'Tech posts',
202
+ feature_image: 'https://example.com/image.jpg',
203
+ visibility: 'public',
204
+ meta_title: 'Tech Posts',
205
+ meta_description: 'Technology articles',
206
+ og_image: 'https://example.com/og.jpg',
207
+ og_title: 'Tech',
208
+ og_description: 'Tech posts',
209
+ twitter_image: 'https://example.com/twitter.jpg',
210
+ twitter_title: 'Tech',
211
+ twitter_description: 'Tech posts',
212
+ codeinjection_head: null,
213
+ codeinjection_foot: null,
214
+ canonical_url: 'https://example.com/tech',
215
+ accent_color: '#FF5733',
216
+ created_at: '2024-01-15T10:30:00.000Z',
217
+ updated_at: '2024-01-15T10:30:00.000Z',
218
+ url: 'https://example.com/tag/technology',
219
+ };
220
+
221
+ expect(() => tagOutputSchema.parse(apiTag)).not.toThrow();
222
+ });
223
+
224
+ it('should accept tag with null optional fields', () => {
225
+ const apiTag = {
226
+ id: '507f1f77bcf86cd799439011',
227
+ name: 'Technology',
228
+ slug: 'technology',
229
+ description: null,
230
+ feature_image: null,
231
+ visibility: 'public',
232
+ meta_title: null,
233
+ meta_description: null,
234
+ og_image: null,
235
+ og_title: null,
236
+ og_description: null,
237
+ twitter_image: null,
238
+ twitter_title: null,
239
+ twitter_description: null,
240
+ codeinjection_head: null,
241
+ codeinjection_foot: null,
242
+ canonical_url: null,
243
+ accent_color: null,
244
+ created_at: '2024-01-15T10:30:00.000Z',
245
+ updated_at: '2024-01-15T10:30:00.000Z',
246
+ url: 'https://example.com/tag/technology',
247
+ };
248
+
249
+ expect(() => tagOutputSchema.parse(apiTag)).not.toThrow();
250
+ });
251
+
252
+ it('should reject tag output without required fields', () => {
253
+ const invalidTag = {
254
+ name: 'Technology',
255
+ slug: 'technology',
256
+ // missing id, created_at, updated_at, url, visibility
257
+ };
258
+
259
+ expect(() => tagOutputSchema.parse(invalidTag)).toThrow();
260
+ });
261
+ });
262
+ });
@@ -0,0 +1,227 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Common Zod schemas for validation across all Ghost MCP resources.
5
+ * These validators provide consistent validation and security controls.
6
+ */
7
+
8
+ // ----- Basic Type Validators -----
9
+
10
+ /**
11
+ * Email validation schema
12
+ * Validates proper email format
13
+ */
14
+ export const emailSchema = z.string().email('Invalid email format');
15
+
16
+ /**
17
+ * URL validation schema
18
+ * Validates proper URL format (http/https)
19
+ */
20
+ export const urlSchema = z.string().url('Invalid URL format');
21
+
22
+ /**
23
+ * ISO 8601 datetime validation schema
24
+ * Validates ISO datetime strings
25
+ */
26
+ export const isoDateSchema = z.string().datetime('Invalid ISO 8601 datetime format');
27
+
28
+ /**
29
+ * Slug validation schema
30
+ * Validates URL-friendly slugs (lowercase alphanumeric with hyphens)
31
+ */
32
+ export const slugSchema = z
33
+ .string()
34
+ .regex(/^[a-z0-9-]+$/, 'Slug must contain only lowercase letters, numbers, and hyphens');
35
+
36
+ /**
37
+ * Ghost ID validation schema
38
+ * Validates 24-character hexadecimal Ghost object IDs
39
+ */
40
+ export const ghostIdSchema = z
41
+ .string()
42
+ .regex(/^[a-f0-9]{24}$/, 'Invalid Ghost ID format (must be 24 hex characters)');
43
+
44
+ // ----- Security Validators -----
45
+
46
+ /**
47
+ * NQL (Ghost Query Language) filter validation schema
48
+ * Prevents injection attacks by restricting allowed characters
49
+ * Allows: alphanumeric, underscores, hyphens, colons, dots, quotes, spaces, commas, brackets, comparison operators
50
+ */
51
+ export const nqlFilterSchema = z
52
+ .string()
53
+ .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid NQL filter: contains disallowed characters')
54
+ .optional();
55
+
56
+ // ----- Pagination Validators -----
57
+
58
+ /**
59
+ * Limit validation schema for pagination
60
+ * Restricts result count between 1 and 100
61
+ */
62
+ export const limitSchema = z
63
+ .number()
64
+ .int('Limit must be an integer')
65
+ .min(1, 'Limit must be at least 1')
66
+ .max(100, 'Limit cannot exceed 100')
67
+ .default(15);
68
+
69
+ /**
70
+ * Page number validation schema for pagination
71
+ * Must be a positive integer starting from 1
72
+ */
73
+ export const pageSchema = z
74
+ .number()
75
+ .int('Page must be an integer')
76
+ .min(1, 'Page must be at least 1')
77
+ .default(1);
78
+
79
+ /**
80
+ * Complete pagination options schema
81
+ */
82
+ export const paginationSchema = z.object({
83
+ limit: limitSchema,
84
+ page: pageSchema,
85
+ });
86
+
87
+ // ----- Status Enum Validators -----
88
+
89
+ /**
90
+ * Post/Page status validation schema
91
+ * Valid values: draft, published, scheduled
92
+ */
93
+ export const postStatusSchema = z.enum(['draft', 'published', 'scheduled'], {
94
+ errorMap: () => ({ message: 'Status must be draft, published, or scheduled' }),
95
+ });
96
+
97
+ /**
98
+ * Visibility validation schema
99
+ * Controls content visibility (public, members, paid, tiers)
100
+ */
101
+ export const visibilitySchema = z.enum(['public', 'members', 'paid', 'tiers'], {
102
+ errorMap: () => ({ message: 'Visibility must be public, members, paid, or tiers' }),
103
+ });
104
+
105
+ // ----- Common Field Validators -----
106
+
107
+ /**
108
+ * HTML content validation schema
109
+ * Validates that content is a non-empty string
110
+ * Note: HTML sanitization should be performed separately
111
+ */
112
+ export const htmlContentSchema = z.string().min(1, 'HTML content cannot be empty');
113
+
114
+ /**
115
+ * Title validation schema
116
+ * Validates post/page titles (1-255 characters)
117
+ */
118
+ export const titleSchema = z
119
+ .string()
120
+ .min(1, 'Title cannot be empty')
121
+ .max(255, 'Title cannot exceed 255 characters');
122
+
123
+ /**
124
+ * Excerpt/description validation schema
125
+ * Optional text snippet (max 500 characters)
126
+ */
127
+ export const excerptSchema = z.string().max(500, 'Excerpt cannot exceed 500 characters').optional();
128
+
129
+ /**
130
+ * Meta title validation schema (SEO)
131
+ * Optional SEO title (max 300 characters)
132
+ */
133
+ export const metaTitleSchema = z
134
+ .string()
135
+ .max(300, 'Meta title cannot exceed 300 characters')
136
+ .optional();
137
+
138
+ /**
139
+ * Meta description validation schema (SEO)
140
+ * Optional SEO description (max 500 characters)
141
+ */
142
+ export const metaDescriptionSchema = z
143
+ .string()
144
+ .max(500, 'Meta description cannot exceed 500 characters')
145
+ .optional();
146
+
147
+ /**
148
+ * Featured flag validation schema
149
+ * Boolean indicating if content is featured
150
+ */
151
+ export const featuredSchema = z.boolean().default(false);
152
+
153
+ /**
154
+ * Feature image URL validation schema
155
+ * Optional URL for featured image
156
+ */
157
+ export const featureImageSchema = z.string().url('Invalid feature image URL').optional();
158
+
159
+ /**
160
+ * Feature image alt text validation schema
161
+ * Optional alt text for accessibility
162
+ */
163
+ export const featureImageAltSchema = z
164
+ .string()
165
+ .max(125, 'Feature image alt text cannot exceed 125 characters')
166
+ .optional();
167
+
168
+ /**
169
+ * Tag name validation schema
170
+ * Tag names (1-191 characters)
171
+ */
172
+ export const tagNameSchema = z
173
+ .string()
174
+ .min(1, 'Tag name cannot be empty')
175
+ .max(191, 'Tag name cannot exceed 191 characters');
176
+
177
+ /**
178
+ * Authors array validation schema
179
+ * Array of Ghost author IDs or email addresses
180
+ */
181
+ export const authorsSchema = z.array(z.string()).optional();
182
+
183
+ /**
184
+ * Tags array validation schema
185
+ * Array of tag names or Ghost tag IDs
186
+ */
187
+ export const tagsSchema = z.array(z.string()).optional();
188
+
189
+ // ----- Utility Validators -----
190
+
191
+ /**
192
+ * Canonical URL validation schema
193
+ * Optional canonical URL for SEO
194
+ */
195
+ export const canonicalUrlSchema = z.string().url('Invalid canonical URL').optional();
196
+
197
+ /**
198
+ * Code injection validation schema
199
+ * Optional HTML/JS code injection (use with caution)
200
+ */
201
+ export const codeInjectionSchema = z
202
+ .object({
203
+ head: z.string().optional(),
204
+ foot: z.string().optional(),
205
+ })
206
+ .optional();
207
+
208
+ /**
209
+ * OG (Open Graph) image validation schema
210
+ * Optional social media image URL
211
+ */
212
+ export const ogImageSchema = z.string().url('Invalid OG image URL').optional();
213
+
214
+ /**
215
+ * Twitter image validation schema
216
+ * Optional Twitter card image URL
217
+ */
218
+ export const twitterImageSchema = z.string().url('Invalid Twitter image URL').optional();
219
+
220
+ /**
221
+ * Custom excerpt validation schema for social media
222
+ * Optional custom excerpt for social sharing
223
+ */
224
+ export const customExcerptSchema = z
225
+ .string()
226
+ .max(300, 'Custom excerpt cannot exceed 300 characters')
227
+ .optional();
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Centralized Zod Schema Library for Ghost MCP Server
3
+ *
4
+ * This module exports all validation schemas for Ghost CMS resources.
5
+ * Use these schemas for consistent input/output validation across all MCP tools.
6
+ *
7
+ * @example
8
+ * import { createPostSchema, tagQuerySchema } from './schemas/index.js';
9
+ *
10
+ * // Validate input
11
+ * const validatedPost = createPostSchema.parse(inputData);
12
+ *
13
+ * // Safe parse with error handling
14
+ * const result = tagQuerySchema.safeParse(queryParams);
15
+ * if (!result.success) {
16
+ * console.error(result.error);
17
+ * }
18
+ */
19
+
20
+ // Common validators and utilities
21
+ export * from './common.js';
22
+
23
+ // Post schemas
24
+ export * from './postSchemas.js';
25
+
26
+ // Page schemas
27
+ export * from './pageSchemas.js';
28
+
29
+ // Tag schemas
30
+ export * from './tagSchemas.js';
31
+
32
+ // Member schemas
33
+ export * from './memberSchemas.js';
34
+
35
+ // Newsletter schemas
36
+ export * from './newsletterSchemas.js';
37
+
38
+ // Tier (membership/product) schemas
39
+ export * from './tierSchemas.js';
@@ -0,0 +1,188 @@
1
+ import { z } from 'zod';
2
+ import { ghostIdSchema, emailSchema } from './common.js';
3
+
4
+ /**
5
+ * Member Schemas for Ghost CMS
6
+ * Provides input/output validation for member operations
7
+ */
8
+
9
+ // ----- Input Schemas -----
10
+
11
+ /**
12
+ * Schema for creating a new member
13
+ * Required: email
14
+ * Optional: name, note, subscribed, labels, etc.
15
+ */
16
+ export const createMemberSchema = z.object({
17
+ email: emailSchema,
18
+ name: z.string().max(191, 'Name cannot exceed 191 characters').optional(),
19
+ note: z.string().max(2000, 'Note cannot exceed 2000 characters').optional(),
20
+ subscribed: z.boolean().default(true).describe('Whether member is subscribed to newsletter'),
21
+ comped: z.boolean().default(false).describe('Whether member has complimentary subscription'),
22
+ labels: z.array(z.string()).optional().describe('Array of label names to associate with member'),
23
+ newsletters: z
24
+ .array(ghostIdSchema)
25
+ .optional()
26
+ .describe('Array of newsletter IDs to subscribe member to'),
27
+ });
28
+
29
+ /**
30
+ * Schema for updating an existing member
31
+ * All fields optional
32
+ */
33
+ export const updateMemberSchema = createMemberSchema.partial();
34
+
35
+ /**
36
+ * Schema for member query/filter parameters
37
+ */
38
+ export const memberQuerySchema = z.object({
39
+ limit: z.number().int().min(1).max(100).default(15).optional(),
40
+ page: z.number().int().min(1).default(1).optional(),
41
+ filter: z
42
+ .string()
43
+ .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
44
+ .optional()
45
+ .describe('NQL filter string (e.g., "status:paid+subscribed:true")'),
46
+ include: z
47
+ .string()
48
+ .optional()
49
+ .describe('Comma-separated list of relations (e.g., "labels,newsletters")'),
50
+ order: z.string().optional().describe('Order results (e.g., "created_at DESC", "name ASC")'),
51
+ search: z.string().optional().describe('Search members by name or email'),
52
+ });
53
+
54
+ /**
55
+ * Schema for member ID parameter
56
+ */
57
+ export const memberIdSchema = z.object({
58
+ id: ghostIdSchema,
59
+ });
60
+
61
+ /**
62
+ * Schema for member email parameter
63
+ */
64
+ export const memberEmailSchema = z.object({
65
+ email: emailSchema,
66
+ });
67
+
68
+ // ----- Output Schemas -----
69
+
70
+ /**
71
+ * Schema for a label object (nested in member)
72
+ */
73
+ export const labelOutputSchema = z.object({
74
+ id: ghostIdSchema,
75
+ name: z.string(),
76
+ slug: z.string(),
77
+ created_at: z.string().datetime(),
78
+ updated_at: z.string().datetime(),
79
+ });
80
+
81
+ /**
82
+ * Schema for a newsletter object (nested in member)
83
+ */
84
+ export const newsletterOutputSchema = z.object({
85
+ id: ghostIdSchema,
86
+ uuid: z.string().uuid(),
87
+ name: z.string(),
88
+ description: z.string().nullable().optional(),
89
+ slug: z.string(),
90
+ sender_name: z.string().nullable().optional(),
91
+ sender_email: z.string().email().nullable().optional(),
92
+ sender_reply_to: z.enum(['newsletter', 'support']).optional(),
93
+ status: z.enum(['active', 'archived']),
94
+ visibility: z.enum(['members', 'paid']),
95
+ subscribe_on_signup: z.boolean(),
96
+ sort_order: z.number(),
97
+ header_image: z.string().url().nullable().optional(),
98
+ show_header_icon: z.boolean(),
99
+ show_header_title: z.boolean(),
100
+ title_font_category: z.string(),
101
+ title_alignment: z.enum(['left', 'center']),
102
+ show_feature_image: z.boolean(),
103
+ body_font_category: z.string(),
104
+ footer_content: z.string().nullable().optional(),
105
+ show_badge: z.boolean(),
106
+ created_at: z.string().datetime(),
107
+ updated_at: z.string().datetime(),
108
+ });
109
+
110
+ /**
111
+ * Schema for member subscription (tier/product)
112
+ */
113
+ export const memberSubscriptionSchema = z.object({
114
+ id: z.string(),
115
+ customer: z.object({
116
+ id: z.string(),
117
+ name: z.string().nullable().optional(),
118
+ email: z.string().email(),
119
+ }),
120
+ plan: z.object({
121
+ id: z.string(),
122
+ nickname: z.string(),
123
+ amount: z.number(),
124
+ interval: z.enum(['month', 'year']),
125
+ currency: z.string(),
126
+ }),
127
+ status: z.enum(['active', 'trialing', 'past_due', 'canceled', 'unpaid']),
128
+ start_date: z.string().datetime(),
129
+ current_period_end: z.string().datetime(),
130
+ cancel_at_period_end: z.boolean(),
131
+ cancellation_reason: z.string().nullable().optional(),
132
+ trial_start_date: z.string().datetime().nullable().optional(),
133
+ trial_end_date: z.string().datetime().nullable().optional(),
134
+ });
135
+
136
+ /**
137
+ * Schema for a Ghost member object (as returned by the API)
138
+ */
139
+ export const memberOutputSchema = z.object({
140
+ id: ghostIdSchema,
141
+ uuid: z.string().uuid(),
142
+ email: z.string().email(),
143
+ name: z.string().nullable().optional(),
144
+ note: z.string().nullable().optional(),
145
+ geolocation: z.string().nullable().optional(),
146
+ enable_comment_notifications: z.boolean(),
147
+ email_count: z.number(),
148
+ email_opened_count: z.number(),
149
+ email_open_rate: z.number().nullable().optional(),
150
+ status: z.enum(['free', 'paid', 'comped']),
151
+ created_at: z.string().datetime(),
152
+ updated_at: z.string().datetime(),
153
+ subscribed: z.boolean(),
154
+ comped: z.boolean(),
155
+ email_suppression: z
156
+ .object({
157
+ suppressed: z.boolean(),
158
+ info: z.string().nullable().optional(),
159
+ })
160
+ .nullable()
161
+ .optional(),
162
+ labels: z.array(labelOutputSchema).optional(),
163
+ subscriptions: z.array(memberSubscriptionSchema).optional(),
164
+ newsletters: z.array(newsletterOutputSchema).optional(),
165
+ avatar_image: z.string().url().nullable().optional(),
166
+ });
167
+
168
+ /**
169
+ * Schema for array of members
170
+ */
171
+ export const membersArraySchema = z.array(memberOutputSchema);
172
+
173
+ /**
174
+ * Schema for paginated member response
175
+ */
176
+ export const membersPaginatedSchema = z.object({
177
+ members: membersArraySchema,
178
+ meta: z.object({
179
+ pagination: z.object({
180
+ page: z.number(),
181
+ limit: z.number(),
182
+ pages: z.number(),
183
+ total: z.number(),
184
+ next: z.number().nullable(),
185
+ prev: z.number().nullable(),
186
+ }),
187
+ }),
188
+ });