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

2
.gitignore vendored
View file

@ -17,10 +17,10 @@ logs
.DS_Store
.fleet
.idea
docs/*
# Local env files
.env
.env.*
!.env.example
/docs/
scripts/*.js

View file

@ -1,5 +1,5 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (import.meta.server) return
if (process.server) return
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()

10
package-lock.json generated
View file

@ -14,7 +14,6 @@
"@nuxt/ui": "^4.0.0",
"@nuxtjs/plausible": "^3.0.1",
"@slack/web-api": "^7.10.0",
"bcryptjs": "^3.0.2",
"chrono-node": "^2.8.4",
"cloudinary": "^2.7.0",
"eslint": "^9.34.0",
@ -7606,15 +7605,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",

View file

@ -19,8 +19,7 @@
"@nuxt/ui": "^4.0.0",
"@nuxtjs/plausible": "^3.0.1",
"@slack/web-api": "^7.10.0",
"bcryptjs": "^3.0.2",
"chrono-node": "^2.8.4",
"chrono-node": "^2.8.4",
"cloudinary": "^2.7.0",
"eslint": "^9.34.0",
"isomorphic-dompurify": "^3.0.0",

View file

@ -1,20 +1,14 @@
import Event from "../../models/event.js";
import { connectDB } from "../../utils/mongoose.js";
import { requireAdmin } from "../../utils/auth.js";
import { validateBody } from "../../utils/validateBody.js";
import { adminEventCreateSchema } from "../../utils/schemas.js";
export default defineEventHandler(async (event) => {
try {
const admin = await requireAdmin(event);
const body = await readBody(event);
// Validate required fields
if (!body.title || !body.description || !body.startDate || !body.endDate) {
throw createError({
statusCode: 400,
statusMessage: "Missing required fields",
});
}
const body = await validateBody(event, adminEventCreateSchema);
await connectDB();

View file

@ -3,6 +3,8 @@ import jwt from "jsonwebtoken";
import { Resend } from "resend";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
import { validateBody } from "../../utils/validateBody.js";
import { emailSchema } from "../../utils/schemas.js";
const resend = new Resend(process.env.RESEND_API_KEY);
@ -10,14 +12,7 @@ export default defineEventHandler(async (event) => {
// Connect to database
await connectDB();
const { email } = await readBody(event);
if (!email) {
throw createError({
statusCode: 400,
statusMessage: "Email is required",
});
}
const { email } = await validateBody(event, emailSchema);
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
@ -31,10 +26,11 @@ export default defineEventHandler(async (event) => {
};
}
// Generate magic link token
// Generate magic link token (use runtime config for consistency with verify/requireAuth)
const config = useRuntimeConfig(event);
const token = jwt.sign(
{ memberId: member._id },
process.env.JWT_SECRET,
config.jwtSecret,
{ expiresIn: "15m" },
);

View file

@ -1,8 +1,8 @@
export default defineEventHandler(async (event) => {
// Clear the auth token cookie
// Clear the auth token cookie (flags must match login for proper clearing)
setCookie(event, 'auth-token', '', {
httpOnly: false, // Match the original cookie settings
secure: false, // Don't require HTTPS in development
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0 // Expire immediately
})

View file

@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
const member = await Member.findById(decoded.memberId).select('-__v')
if (!member) {

View file

@ -18,8 +18,9 @@ export default defineEventHandler(async (event) => {
}
try {
// Verify the JWT token
const decoded = jwt.verify(token, process.env.JWT_SECRET)
// Verify the JWT token (use runtime config for consistency with login/requireAuth)
const config = useRuntimeConfig(event)
const decoded = jwt.verify(token, config.jwtSecret)
const member = await Member.findById(decoded.memberId)
if (!member) {
@ -32,7 +33,7 @@ export default defineEventHandler(async (event) => {
// Create a new session token for the authenticated user
const sessionToken = jwt.sign(
{ memberId: member._id, email: member.email },
process.env.JWT_SECRET,
config.jwtSecret,
{ expiresIn: '7d' }
)

View file

@ -2,6 +2,8 @@ import Event from "../../../models/event.js";
import Member from "../../../models/member.js";
import { connectDB } from "../../../utils/mongoose.js";
import { sendEventRegistrationEmail } from "../../../utils/resend.js";
import { validateBody } from "../../../utils/validateBody.js";
import { eventRegistrationSchema } from "../../../utils/schemas.js";
import mongoose from "mongoose";
export default defineEventHandler(async (event) => {
@ -9,7 +11,7 @@ export default defineEventHandler(async (event) => {
// Ensure database connection
await connectDB();
const identifier = getRouterParam(event, "id");
const body = await readBody(event);
const body = await validateBody(event, eventRegistrationSchema);
if (!identifier) {
throw createError({
@ -18,14 +20,6 @@ export default defineEventHandler(async (event) => {
});
}
// Validate required fields
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: "Name and email are required",
});
}
// Fetch the event - try by slug first, then by ID
let eventData;

View file

@ -92,7 +92,6 @@ export default defineEventHandler(async (event) => {
// Optional: Verify the transaction with Helcim API
// This adds extra security to ensure the transaction is legitimate
// For now, we trust the transaction ID from HelcimPay.js
console.log("Payment completed with transaction ID:", transactionId);
}
// Create registration

View file

@ -16,7 +16,6 @@ export default defineEventHandler(async (event) => {
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Creating payment plan:', body.name)
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'POST',
@ -44,7 +43,6 @@ export default defineEventHandler(async (event) => {
}
const planData = await response.json()
console.log('Payment plan created:', planData)
return {
success: true,

View file

@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
// Decode JWT token
let decoded
try {
decoded = jwt.verify(token, process.env.JWT_SECRET)
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) {
throw createError({
statusCode: 401,

View file

@ -105,7 +105,7 @@ export default defineEventHandler(async (event) => {
email: body.email,
helcimCustomerId: customerData.id
},
process.env.JWT_SECRET,
config.jwtSecret,
{ expiresIn: '24h' }
)

View file

@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
// Decode JWT token
let decoded
try {
decoded = jwt.verify(token, process.env.JWT_SECRET)
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) {
throw createError({
statusCode: 401,
@ -59,7 +59,6 @@ export default defineEventHandler(async (event) => {
const existingCustomer = searchData.customers.find(c => c.email === member.email)
if (existingCustomer) {
console.log('Found existing Helcim customer:', existingCustomer.id)
// Update member record with customer ID if not already set
if (!member.helcimCustomerId) {
@ -77,12 +76,11 @@ export default defineEventHandler(async (event) => {
}
}
} catch (searchError) {
console.log('Error searching for customer:', searchError)
console.error('Error searching for customer:', searchError)
// Continue to create new customer
}
// No existing customer found, create new one
console.log('Creating new Helcim customer for:', member.email)
const createResponse = await fetch(`${HELCIM_API_BASE}/customers`, {
method: 'POST',
headers: {
@ -107,7 +105,6 @@ export default defineEventHandler(async (event) => {
}
const customerData = await createResponse.json()
console.log('Created Helcim customer:', customerData.id)
// Update member record with customer ID
member.helcimCustomerId = customerData.id

View file

@ -6,8 +6,6 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Fetching payment plans from Helcim...')
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'GET',
headers: {
@ -18,17 +16,13 @@ export default defineEventHandler(async (event) => {
if (!response.ok) {
console.error('Failed to fetch payment plans:', response.status, response.statusText)
const errorText = await response.text()
console.error('Response body:', errorText)
throw createError({
statusCode: response.status,
statusMessage: `Failed to fetch payment plans: ${errorText}`
statusMessage: 'Failed to fetch payment plans'
})
}
const plansData = await response.json()
console.log('Payment plans retrieved:', JSON.stringify(plansData, null, 2))
return {
success: true,

View file

@ -6,8 +6,6 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Fetching existing subscriptions from Helcim...')
const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'GET',
headers: {
@ -18,17 +16,13 @@ export default defineEventHandler(async (event) => {
if (!response.ok) {
console.error('Failed to fetch subscriptions:', response.status, response.statusText)
const errorText = await response.text()
console.error('Response body:', errorText)
throw createError({
statusCode: response.status,
statusMessage: `Failed to fetch subscriptions: ${errorText}`
statusMessage: 'Failed to fetch subscriptions'
})
}
const subscriptionsData = await response.json()
console.log('Existing subscriptions:', JSON.stringify(subscriptionsData, null, 2))
return {
success: true,

View file

@ -1,46 +0,0 @@
// Test Helcim API connection
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
// Log token info (safely)
const tokenInfo = {
hasToken: !!config.public.helcimToken,
tokenLength: config.public.helcimToken ? config.public.helcimToken.length : 0,
tokenPrefix: config.public.helcimToken ? config.public.helcimToken.substring(0, 10) : null
}
console.log('Helcim Token Info:', tokenInfo)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
// Try connection test endpoint
const response = await $fetch(`${HELCIM_API_BASE}/connection-test`, {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
return {
success: true,
message: 'Helcim API connection successful',
tokenInfo,
connectionResponse: response
}
} catch (error) {
console.error('Helcim test error:', error)
return {
success: false,
message: error.message || 'Failed to connect to Helcim API',
statusCode: error.statusCode,
tokenInfo: {
hasToken: !!useRuntimeConfig().public.helcimToken,
tokenLength: useRuntimeConfig().public.helcimToken ? useRuntimeConfig().public.helcimToken.length : 0
}
}
}
})

View file

@ -1,77 +0,0 @@
// Test minimal subscription creation to understand required fields
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
// Generate a 25-character idempotency key
const idempotencyKey = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`.substring(0, 25)
// Test with minimal fields first
const testRequest1 = {
customerCode: 'CST1020', // Use a recent customer code
planId: 20162
}
console.log('Testing subscription with minimal fields:', testRequest1)
try {
const response1 = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken,
'idempotency-key': idempotencyKey + 'a'
},
body: JSON.stringify(testRequest1)
})
const result1 = await response1.text()
console.log('Test 1 - Status:', response1.status)
console.log('Test 1 - Response:', result1)
if (!response1.ok) {
// Try with paymentPlanId instead
const testRequest2 = {
customerCode: 'CST1020',
paymentPlanId: 20162
}
console.log('Testing subscription with paymentPlanId:', testRequest2)
const response2 = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken,
'idempotency-key': idempotencyKey + 'b'
},
body: JSON.stringify(testRequest2)
})
const result2 = await response2.text()
console.log('Test 2 - Status:', response2.status)
console.log('Test 2 - Response:', result2)
}
} catch (error) {
console.error('Test error:', error)
}
return {
success: true,
message: 'Check server logs for test results'
}
} catch (error) {
console.error('Error in test endpoint:', error)
throw createError({
statusCode: 500,
statusMessage: error.message
})
}
})

View file

@ -1,5 +1,7 @@
// Verify payment token from HelcimPay.js
import { requireAuth } from '../../utils/auth.js'
import { validateBody } from '../../utils/validateBody.js'
import { paymentVerifySchema } from '../../utils/schemas.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
@ -7,15 +9,7 @@ export default defineEventHandler(async (event) => {
try {
await requireAuth(event)
const config = useRuntimeConfig(event)
const body = await readBody(event)
// Validate required fields
if (!body.cardToken || !body.customerId) {
throw createError({
statusCode: 400,
statusMessage: 'Card token and customer ID are required'
})
}
const body = await validateBody(event, paymentVerifySchema)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
@ -48,13 +42,13 @@ export default defineEventHandler(async (event) => {
// Verify the card token exists for this customer
const cardExists = Array.isArray(cards) && cards.some(card =>
card.cardToken === body.cardToken || card.id
card.cardToken === body.cardToken
)
if (!cardExists && Array.isArray(cards) && cards.length === 0) {
if (!cardExists) {
throw createError({
statusCode: 400,
statusMessage: 'No payment method found for this customer'
statusMessage: 'Payment method not found or does not belong to this customer'
})
}

View file

@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
// Decode JWT token
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
decoded = jwt.verify(token, config.jwtSecret);
} catch (err) {
throw createError({
statusCode: 401,

View file

@ -2,6 +2,8 @@
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import { getSlackService } from '../../utils/slack.ts'
import { validateBody } from '../../utils/validateBody.js'
import { memberCreateSchema } from '../../utils/schemas.js'
// Simple payment check function to avoid import issues
const requiresPayment = (contributionValue) => contributionValue !== '0'
@ -14,7 +16,7 @@ async function inviteToSlack(member) {
return
}
console.log(`Processing Slack invitation for ${member.email}...`)
console.warn(`Processing Slack invitation for member`)
const inviteResult = await slackService.inviteUserToSlack(
member.email,
@ -45,13 +47,13 @@ async function inviteToSlack(member) {
inviteResult.status
)
console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`)
console.warn(`Slack invitation processed: ${inviteResult.status}`)
} else {
// Update member record to reflect failed invitation
member.slackInviteStatus = 'failed'
await member.save()
console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`)
console.error(`Failed to process Slack invitation: ${inviteResult.error}`)
// Don't throw error - member creation should still succeed
}
} catch (error) {
@ -73,32 +75,30 @@ export default defineEventHandler(async (event) => {
// Ensure database is connected
await connectDB()
const body = await readBody(event)
const validatedData = await validateBody(event, memberCreateSchema)
try {
// Check if member already exists
const existingMember = await Member.findOne({ email: body.email })
const existingMember = await Member.findOne({ email: validatedData.email })
if (existingMember) {
throw createError({
statusCode: 409,
statusMessage: 'A member with this email already exists'
throw createError({
statusCode: 409,
statusMessage: 'A member with this email already exists'
})
}
const member = new Member(body)
const member = new Member(validatedData)
await member.save()
// Send Slack invitation for new members
await inviteToSlack(member)
// TODO: Process payment with Helcim if not free tier
if (requiresPayment(body.contributionTier)) {
if (requiresPayment(validatedData.contributionTier)) {
// Payment processing will be added here
console.log('Payment processing needed for tier:', body.contributionTier)
}
// TODO: Send welcome email
console.log('Welcome email should be sent to:', body.email)
return { success: true, member }
} catch (error) {

View file

@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
currentMemberId = decoded.memberId;
isAuthenticated = true;
} catch (err) {

View file

@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId;
} catch (err) {
throw createError({

View file

@ -1,14 +1,16 @@
import Member from "../../models/member.js";
import { requireAuth } from "../../utils/auth.js";
import { validateBody } from "../../utils/validateBody.js";
import { memberProfileUpdateSchema } from "../../utils/schemas.js";
export default defineEventHandler(async (event) => {
const authedMember = await requireAuth(event);
const memberId = authedMember._id;
const body = await readBody(event);
const body = await validateBody(event, memberProfileUpdateSchema);
// Define allowed profile fields
const allowedFields = [
// Profile fields from validated body
const profileFields = [
"pronouns",
"timeZone",
"avatar",
@ -19,7 +21,7 @@ export default defineEventHandler(async (event) => {
"showInDirectory",
];
// Define privacy fields
// Privacy fields from validated body
const privacyFields = [
"pronounsPrivacy",
"timeZonePrivacy",
@ -32,10 +34,10 @@ export default defineEventHandler(async (event) => {
"lookingForPrivacy",
];
// Build update object
// Build update object from validated data
const updateData = {};
allowedFields.forEach((field) => {
profileFields.forEach((field) => {
if (body[field] !== undefined) {
updateData[field] = body[field];
}
@ -73,7 +75,7 @@ export default defineEventHandler(async (event) => {
if (!member) {
throw createError({
statusCode: 404,
message: "Member not found",
statusMessage: "Member not found",
});
}
@ -99,7 +101,7 @@ export default defineEventHandler(async (event) => {
console.error("Profile update error:", error);
throw createError({
statusCode: 500,
message: "Failed to update profile",
statusMessage: "Failed to update profile",
});
}
});

View file

@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
// Decode JWT token
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
} catch (err) {
throw createError({
statusCode: 401,

View file

@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
if (token) {
try {
jwt.verify(token, process.env.JWT_SECRET);
jwt.verify(token, useRuntimeConfig().jwtSecret);
isAuthenticated = true;
} catch (err) {
isAuthenticated = false;

View file

@ -1,68 +0,0 @@
import { WebClient } from '@slack/web-api'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
if (!config.slackBotToken) {
return {
success: false,
error: 'Slack bot token not configured'
}
}
const client = new WebClient(config.slackBotToken)
try {
// Test basic API access
const authTest = await client.auth.test()
console.log('Auth test result:', authTest)
// Test if admin API is available
let adminApiAvailable = false
let adminError = null
try {
// Try to call admin.users.list to test admin API access
await client.admin.users.list({ limit: 1 })
adminApiAvailable = true
} catch (error: any) {
adminError = error.data?.error || error.message
console.log('Admin API test failed:', adminError)
}
// Test channel access if channel ID is configured
let channelAccess = false
let channelError = null
if (config.slackVettingChannelId) {
try {
const channelInfo = await client.conversations.info({
channel: config.slackVettingChannelId
})
channelAccess = !!channelInfo.channel
} catch (error: any) {
channelError = error.data?.error || error.message
}
}
return {
success: true,
botInfo: {
user: authTest.user,
team: authTest.team,
url: authTest.url
},
adminApiAvailable,
adminError: adminApiAvailable ? null : adminError,
channelAccess,
channelError: channelAccess ? null : channelError,
channelId: config.slackVettingChannelId || 'Not configured'
}
} catch (error: any) {
return {
success: false,
error: error.data?.error || error.message || 'Unknown error'
}
}
})

View file

@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId;
} catch (err) {
throw createError({ statusCode: 401, statusMessage: "Invalid token" });

View file

@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId;
} catch (err) {
throw createError({

View file

@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
// Check if user is authenticated
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId;
} catch (err) {
// Token invalid, continue as non-member

View file

@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId;
} catch (err) {
throw createError({

View file

@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
// Check if user is authenticated
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId;
} catch (err) {
// Token invalid, continue as non-member

View file

@ -1,6 +1,8 @@
import jwt from "jsonwebtoken";
import Update from "../../models/update.js";
import { connectDB } from "../../utils/mongoose.js";
import { validateBody } from "../../utils/validateBody.js";
import { updateCreateSchema } from "../../utils/schemas.js";
export default defineEventHandler(async (event) => {
await connectDB();
@ -16,7 +18,7 @@ export default defineEventHandler(async (event) => {
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId;
} catch (err) {
throw createError({
@ -25,14 +27,7 @@ export default defineEventHandler(async (event) => {
});
}
const body = await readBody(event);
if (!body.content || !body.content.trim()) {
throw createError({
statusCode: 400,
statusMessage: "Content is required",
});
}
const body = await validateBody(event, updateCreateSchema);
try {
const update = await Update.create({

View file

@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId;
} catch (err) {
throw createError({

View file

@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
// Check if user is authenticated
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
currentMemberId = decoded.memberId;
} catch (err) {
// Token invalid, continue as non-member

View file

@ -39,6 +39,15 @@ export default defineEventHandler(async (event) => {
})
}
// Validate file size (10MB limit)
const maxSize = 10 * 1024 * 1024
if (fileData.data.length > maxSize) {
throw createError({
statusCode: 400,
statusMessage: 'File too large. Maximum size is 10MB.'
})
}
// Convert buffer to base64 for Cloudinary upload
const base64File = `data:${fileData.type};base64,${fileData.data.toString('base64')}`

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
}

View file

@ -107,7 +107,7 @@ describe('auth login endpoint', () => {
await expect(loginHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Email is required'
statusMessage: 'Validation failed'
})
})
})

View file

@ -0,0 +1,273 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
import {
emailSchema,
memberCreateSchema,
memberProfileUpdateSchema,
eventRegistrationSchema,
updateCreateSchema,
paymentVerifySchema,
adminEventCreateSchema
} from '../../../server/utils/schemas.js'
import { validateBody } from '../../../server/utils/validateBody.js'
// --- Schema unit tests ---
describe('emailSchema', () => {
it('accepts a valid email', () => {
const result = emailSchema.safeParse({ email: 'test@example.com' })
expect(result.success).toBe(true)
expect(result.data.email).toBe('test@example.com')
})
it('rejects a malformed email', () => {
const result = emailSchema.safeParse({ email: 'not-an-email' })
expect(result.success).toBe(false)
})
it('rejects missing email', () => {
const result = emailSchema.safeParse({})
expect(result.success).toBe(false)
})
it('trims and lowercases email', () => {
const result = emailSchema.safeParse({ email: ' Test@EXAMPLE.COM ' })
expect(result.success).toBe(true)
expect(result.data.email).toBe('test@example.com')
})
})
describe('memberCreateSchema', () => {
const validMember = {
email: 'new@example.com',
name: 'Test User',
circle: 'community',
contributionTier: '0'
}
it('accepts valid member data', () => {
const result = memberCreateSchema.safeParse(validMember)
expect(result.success).toBe(true)
})
it('rejects role field (mass assignment)', () => {
const result = memberCreateSchema.safeParse({ ...validMember, role: 'admin' })
expect(result.success).toBe(true)
// role should NOT be in the output
expect(result.data).not.toHaveProperty('role')
})
it('rejects status field (mass assignment)', () => {
const result = memberCreateSchema.safeParse({ ...validMember, status: 'active' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('status')
})
it('rejects helcimCustomerId field (mass assignment)', () => {
const result = memberCreateSchema.safeParse({ ...validMember, helcimCustomerId: 'cust_123' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('helcimCustomerId')
})
it('rejects _id field (mass assignment)', () => {
const result = memberCreateSchema.safeParse({ ...validMember, _id: '507f1f77bcf86cd799439011' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('_id')
})
it('rejects invalid circle enum', () => {
const result = memberCreateSchema.safeParse({ ...validMember, circle: 'superadmin' })
expect(result.success).toBe(false)
})
it('rejects invalid contributionTier enum', () => {
const result = memberCreateSchema.safeParse({ ...validMember, contributionTier: '999' })
expect(result.success).toBe(false)
})
it('rejects missing required fields', () => {
const result = memberCreateSchema.safeParse({ email: 'test@example.com' })
expect(result.success).toBe(false)
})
it('lowercases email', () => {
const result = memberCreateSchema.safeParse({ ...validMember, email: 'NEW@Example.COM' })
expect(result.success).toBe(true)
expect(result.data.email).toBe('new@example.com')
})
})
describe('eventRegistrationSchema', () => {
it('accepts valid registration', () => {
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'jane@example.com' })
expect(result.success).toBe(true)
})
it('rejects missing name', () => {
const result = eventRegistrationSchema.safeParse({ email: 'jane@example.com' })
expect(result.success).toBe(false)
})
it('rejects malformed email', () => {
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'bad' })
expect(result.success).toBe(false)
})
it('lowercases email', () => {
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'JANE@Example.COM' })
expect(result.success).toBe(true)
expect(result.data.email).toBe('jane@example.com')
})
})
describe('updateCreateSchema', () => {
it('accepts valid content', () => {
const result = updateCreateSchema.safeParse({ content: 'Hello world' })
expect(result.success).toBe(true)
})
it('rejects empty content', () => {
const result = updateCreateSchema.safeParse({ content: '' })
expect(result.success).toBe(false)
})
it('rejects content exceeding 50000 chars', () => {
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50001) })
expect(result.success).toBe(false)
})
it('accepts content at exactly 50000 chars', () => {
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50000) })
expect(result.success).toBe(true)
})
it('validates images are URLs', () => {
const result = updateCreateSchema.safeParse({
content: 'test',
images: ['not-a-url']
})
expect(result.success).toBe(false)
})
it('accepts valid images array', () => {
const result = updateCreateSchema.safeParse({
content: 'test',
images: ['https://example.com/img.png']
})
expect(result.success).toBe(true)
})
it('rejects more than 20 images', () => {
const images = Array.from({ length: 21 }, (_, i) => `https://example.com/img${i}.png`)
const result = updateCreateSchema.safeParse({ content: 'test', images })
expect(result.success).toBe(false)
})
it('validates privacy enum', () => {
const result = updateCreateSchema.safeParse({ content: 'test', privacy: 'invalid' })
expect(result.success).toBe(false)
})
})
describe('paymentVerifySchema', () => {
it('accepts valid card token and customer ID', () => {
const result = paymentVerifySchema.safeParse({ cardToken: 'tok_123', customerId: 'cust_456' })
expect(result.success).toBe(true)
})
it('rejects missing cardToken', () => {
const result = paymentVerifySchema.safeParse({ customerId: 'cust_456' })
expect(result.success).toBe(false)
})
it('rejects empty cardToken', () => {
const result = paymentVerifySchema.safeParse({ cardToken: '', customerId: 'cust_456' })
expect(result.success).toBe(false)
})
})
describe('adminEventCreateSchema', () => {
const validEvent = {
title: 'Test Event',
description: 'A test event',
startDate: '2026-04-01T10:00:00Z',
endDate: '2026-04-01T12:00:00Z'
}
it('accepts valid event data', () => {
const result = adminEventCreateSchema.safeParse(validEvent)
expect(result.success).toBe(true)
})
it('rejects missing title', () => {
const { title, ...rest } = validEvent
const result = adminEventCreateSchema.safeParse(rest)
expect(result.success).toBe(false)
})
it('rejects missing dates', () => {
const { startDate, endDate, ...rest } = validEvent
const result = adminEventCreateSchema.safeParse({ ...rest, title: 'Test' })
expect(result.success).toBe(false)
})
})
describe('memberProfileUpdateSchema', () => {
it('rejects role in profile update', () => {
const result = memberProfileUpdateSchema.safeParse({ role: 'admin', bio: 'test' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('role')
})
it('rejects status in profile update', () => {
const result = memberProfileUpdateSchema.safeParse({ status: 'active', bio: 'test' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('status')
})
it('validates privacy enum values', () => {
const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'invalid' })
expect(result.success).toBe(false)
})
it('accepts valid privacy values', () => {
const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'public' })
expect(result.success).toBe(true)
})
})
// --- validateBody integration tests ---
describe('validateBody', () => {
it('returns validated data on success', async () => {
const event = createMockEvent({
method: 'POST',
body: { email: 'test@example.com' }
})
const data = await validateBody(event, emailSchema)
expect(data.email).toBe('test@example.com')
})
it('throws 400 on validation failure', async () => {
const event = createMockEvent({
method: 'POST',
body: { email: 'bad' }
})
await expect(validateBody(event, emailSchema)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Validation failed'
})
})
it('strips unknown fields from output', async () => {
const event = createMockEvent({
method: 'POST',
body: { email: 'test@example.com', name: 'Test', circle: 'community', contributionTier: '0', role: 'admin', _id: 'fake' }
})
const data = await validateBody(event, memberCreateSchema)
expect(data).not.toHaveProperty('role')
expect(data).not.toHaveProperty('_id')
expect(data.email).toBe('test@example.com')
expect(data.name).toBe('Test')
})
})