Add Zod validation, fix mass assignment, remove test endpoints and dead code

- Add centralized Zod schemas (server/utils/schemas.js) and validateBody
  utility for all API endpoints
- Fix critical mass assignment in member creation: raw body no longer
  passed to new Member(), only validated fields (email, name, circle,
  contributionTier) are accepted
- Apply Zod validation to login, profile patch, event registration,
  updates, verify-payment, and admin event creation endpoints
- Fix logout cookie flags to match login (httpOnly: true, secure
  conditional on NODE_ENV)
- Delete unauthenticated test/debug endpoints (test-connection,
  test-subscription, test-bot)
- Remove sensitive console.log statements from Helcim and member
  endpoints
- Remove unused bcryptjs dependency
- Add 10MB file size limit on image uploads
- Use runtime config for JWT secret across all endpoints
- Add 38 validation tests (117 total, all passing)
This commit is contained in:
Jennie Robinson Faber 2026-03-01 14:02:46 +00:00
parent 26c300c357
commit b7279f57d1
41 changed files with 467 additions and 321 deletions

96
server/utils/schemas.js Normal file
View file

@ -0,0 +1,96 @@
import * as z from 'zod'
const privacyEnum = z.enum(['public', 'members', 'private'])
export const emailSchema = z.object({
email: z.string().trim().toLowerCase().email()
})
export const memberCreateSchema = z.object({
email: z.string().trim().toLowerCase().email(),
name: z.string().min(1).max(200),
circle: z.enum(['community', 'founder', 'practitioner']),
contributionTier: z.enum(['0', '5', '15', '30', '50'])
})
export const memberProfileUpdateSchema = z.object({
pronouns: z.string().max(100).optional(),
timeZone: z.string().max(100).optional(),
avatar: z.union([z.string().url().max(500), z.literal('')]).optional(),
studio: z.string().max(200).optional(),
bio: z.string().max(5000).optional(),
location: z.string().max(200).optional(),
socialLinks: z.object({
mastodon: z.string().max(300).optional(),
linkedin: z.string().max(300).optional(),
website: z.string().max(300).optional(),
other: z.string().max(300).optional()
}).optional(),
offering: z.object({
text: z.string().max(2000).optional(),
tags: z.array(z.string().max(100)).max(20).optional()
}).optional(),
lookingFor: z.object({
text: z.string().max(2000).optional(),
tags: z.array(z.string().max(100)).max(20).optional()
}).optional(),
showInDirectory: z.boolean().optional(),
pronounsPrivacy: privacyEnum.optional(),
timeZonePrivacy: privacyEnum.optional(),
avatarPrivacy: privacyEnum.optional(),
studioPrivacy: privacyEnum.optional(),
bioPrivacy: privacyEnum.optional(),
locationPrivacy: privacyEnum.optional(),
socialLinksPrivacy: privacyEnum.optional(),
offeringPrivacy: privacyEnum.optional(),
lookingForPrivacy: privacyEnum.optional()
})
export const eventRegistrationSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().trim().toLowerCase().email(),
dietary: z.boolean().optional()
})
export const updateCreateSchema = z.object({
content: z.string().min(1).max(50000),
images: z.array(z.string().url()).max(20).optional(),
privacy: z.enum(['public', 'members', 'private']).optional(),
commentsEnabled: z.boolean().optional()
})
export const paymentVerifySchema = z.object({
cardToken: z.string().min(1),
customerId: z.string().min(1)
})
export const adminEventCreateSchema = z.object({
title: z.string().min(1).max(500),
description: z.string().min(1).max(50000),
startDate: z.string().min(1),
endDate: z.string().min(1),
location: z.string().max(500).optional(),
maxAttendees: z.number().int().positive().optional(),
membersOnly: z.boolean().optional(),
registrationDeadline: z.string().optional(),
pricing: z.object({
paymentRequired: z.boolean().optional(),
isFree: z.boolean().optional()
}).optional(),
tickets: z.object({
enabled: z.boolean().optional(),
public: z.object({
available: z.boolean().optional(),
name: z.string().max(200).optional(),
description: z.string().max(2000).optional(),
price: z.number().min(0).optional(),
quantity: z.number().int().positive().optional(),
earlyBirdPrice: z.number().min(0).optional(),
earlyBirdDeadline: z.string().optional()
}).optional()
}).optional(),
image: z.string().url().optional(),
category: z.string().max(100).optional(),
tags: z.array(z.string().max(100)).max(20).optional(),
series: z.string().optional()
})

View file

@ -0,0 +1,12 @@
export async function validateBody(event, schema) {
const body = await readBody(event)
const result = schema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
statusMessage: 'Validation failed',
data: result.error.flatten().fieldErrors
})
}
return result.data
}