diff --git a/.gitignore b/.gitignore index 400f35a..a3170a0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,10 +17,10 @@ logs .DS_Store .fleet .idea +docs/* # Local env files .env .env.* !.env.example -/docs/ scripts/*.js diff --git a/app/middleware/admin.js b/app/middleware/admin.js index 0f86913..cf2d155 100644 --- a/app/middleware/admin.js +++ b/app/middleware/admin.js @@ -1,5 +1,5 @@ export default defineNuxtRouteMiddleware(async (to) => { - if (import.meta.server) return + if (process.server) return const { isAuthenticated, memberData, checkMemberStatus } = useAuth() diff --git a/package-lock.json b/package-lock.json index 1cf5acf..1ba137f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dd20eb1..7cbfc71 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api/admin/events.post.js b/server/api/admin/events.post.js index ed07488..875499b 100644 --- a/server/api/admin/events.post.js +++ b/server/api/admin/events.post.js @@ -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(); diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js index 0806a10..b60986f 100644 --- a/server/api/auth/login.post.js +++ b/server/api/auth/login.post.js @@ -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" }, ); diff --git a/server/api/auth/logout.post.js b/server/api/auth/logout.post.js index 56c7fdf..077d38a 100644 --- a/server/api/auth/logout.post.js +++ b/server/api/auth/logout.post.js @@ -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 }) diff --git a/server/api/auth/status.get.js b/server/api/auth/status.get.js index 4d526fa..ec47164 100644 --- a/server/api/auth/status.get.js +++ b/server/api/auth/status.get.js @@ -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) { diff --git a/server/api/auth/verify.get.js b/server/api/auth/verify.get.js index 3068ed7..d5f4c1f 100644 --- a/server/api/auth/verify.get.js +++ b/server/api/auth/verify.get.js @@ -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' } ) diff --git a/server/api/events/[id]/register.post.js b/server/api/events/[id]/register.post.js index 7dfc1f0..ce2bbcb 100644 --- a/server/api/events/[id]/register.post.js +++ b/server/api/events/[id]/register.post.js @@ -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; diff --git a/server/api/events/[id]/tickets/purchase.post.js b/server/api/events/[id]/tickets/purchase.post.js index 197684c..43588fc 100644 --- a/server/api/events/[id]/tickets/purchase.post.js +++ b/server/api/events/[id]/tickets/purchase.post.js @@ -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 diff --git a/server/api/helcim/create-plan.post.js b/server/api/helcim/create-plan.post.js index 32e3672..9b9c1a4 100644 --- a/server/api/helcim/create-plan.post.js +++ b/server/api/helcim/create-plan.post.js @@ -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, diff --git a/server/api/helcim/customer-code.get.js b/server/api/helcim/customer-code.get.js index 7ee0be0..90e21af 100644 --- a/server/api/helcim/customer-code.get.js +++ b/server/api/helcim/customer-code.get.js @@ -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, diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index 2e20202..3e8c7ee 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -105,7 +105,7 @@ export default defineEventHandler(async (event) => { email: body.email, helcimCustomerId: customerData.id }, - process.env.JWT_SECRET, + config.jwtSecret, { expiresIn: '24h' } ) diff --git a/server/api/helcim/get-or-create-customer.post.js b/server/api/helcim/get-or-create-customer.post.js index c842913..f294a32 100644 --- a/server/api/helcim/get-or-create-customer.post.js +++ b/server/api/helcim/get-or-create-customer.post.js @@ -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 diff --git a/server/api/helcim/plans.get.js b/server/api/helcim/plans.get.js index e4da1ce..6b71c92 100644 --- a/server/api/helcim/plans.get.js +++ b/server/api/helcim/plans.get.js @@ -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, diff --git a/server/api/helcim/subscriptions.get.js b/server/api/helcim/subscriptions.get.js index f103ed3..111e33d 100644 --- a/server/api/helcim/subscriptions.get.js +++ b/server/api/helcim/subscriptions.get.js @@ -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, diff --git a/server/api/helcim/test-connection.get.js b/server/api/helcim/test-connection.get.js deleted file mode 100644 index c4ad093..0000000 --- a/server/api/helcim/test-connection.get.js +++ /dev/null @@ -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 - } - } - } -}) \ No newline at end of file diff --git a/server/api/helcim/test-subscription.get.js b/server/api/helcim/test-subscription.get.js deleted file mode 100644 index ff8b5e6..0000000 --- a/server/api/helcim/test-subscription.get.js +++ /dev/null @@ -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 - }) - } -}) \ No newline at end of file diff --git a/server/api/helcim/verify-payment.post.js b/server/api/helcim/verify-payment.post.js index b6c9ffd..16fc074 100644 --- a/server/api/helcim/verify-payment.post.js +++ b/server/api/helcim/verify-payment.post.js @@ -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' }) } diff --git a/server/api/members/cancel-subscription.post.js b/server/api/members/cancel-subscription.post.js index 955bf59..34e82cb 100644 --- a/server/api/members/cancel-subscription.post.js +++ b/server/api/members/cancel-subscription.post.js @@ -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, diff --git a/server/api/members/create.post.js b/server/api/members/create.post.js index 1ed831c..b9ae293 100644 --- a/server/api/members/create.post.js +++ b/server/api/members/create.post.js @@ -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) { diff --git a/server/api/members/directory.get.js b/server/api/members/directory.get.js index c880480..3598647 100644 --- a/server/api/members/directory.get.js +++ b/server/api/members/directory.get.js @@ -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) { diff --git a/server/api/members/me/peer-support.patch.js b/server/api/members/me/peer-support.patch.js index e9b9887..b130346 100644 --- a/server/api/members/me/peer-support.patch.js +++ b/server/api/members/me/peer-support.patch.js @@ -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({ diff --git a/server/api/members/profile.patch.js b/server/api/members/profile.patch.js index 83160da..58993ce 100644 --- a/server/api/members/profile.patch.js +++ b/server/api/members/profile.patch.js @@ -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", }); } }); diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js index 7ff8736..6749880 100644 --- a/server/api/members/update-contribution.post.js +++ b/server/api/members/update-contribution.post.js @@ -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, diff --git a/server/api/peer-support.get.js b/server/api/peer-support.get.js index 04c5977..e99f68c 100644 --- a/server/api/peer-support.get.js +++ b/server/api/peer-support.get.js @@ -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; diff --git a/server/api/slack/test-bot.get.ts b/server/api/slack/test-bot.get.ts deleted file mode 100644 index 65b8ba5..0000000 --- a/server/api/slack/test-bot.get.ts +++ /dev/null @@ -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' - } - } -}) \ No newline at end of file diff --git a/server/api/test/peer-support-debug.get.js b/server/api/test/peer-support-debug.get.js index 09c169d..4073cb2 100644 --- a/server/api/test/peer-support-debug.get.js +++ b/server/api/test/peer-support-debug.get.js @@ -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" }); diff --git a/server/api/updates/[id].delete.js b/server/api/updates/[id].delete.js index ed7dda2..aeedd95 100644 --- a/server/api/updates/[id].delete.js +++ b/server/api/updates/[id].delete.js @@ -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({ diff --git a/server/api/updates/[id].get.js b/server/api/updates/[id].get.js index 6dda80f..d17ecaf 100644 --- a/server/api/updates/[id].get.js +++ b/server/api/updates/[id].get.js @@ -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 diff --git a/server/api/updates/[id].patch.js b/server/api/updates/[id].patch.js index fcd5e5c..a1cf726 100644 --- a/server/api/updates/[id].patch.js +++ b/server/api/updates/[id].patch.js @@ -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({ diff --git a/server/api/updates/index.get.js b/server/api/updates/index.get.js index 2cb28fb..f6f5d90 100644 --- a/server/api/updates/index.get.js +++ b/server/api/updates/index.get.js @@ -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 diff --git a/server/api/updates/index.post.js b/server/api/updates/index.post.js index 97d659a..45ea3e6 100644 --- a/server/api/updates/index.post.js +++ b/server/api/updates/index.post.js @@ -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({ diff --git a/server/api/updates/my-updates.get.js b/server/api/updates/my-updates.get.js index 60e6da5..084d787 100644 --- a/server/api/updates/my-updates.get.js +++ b/server/api/updates/my-updates.get.js @@ -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({ diff --git a/server/api/updates/user/[id].get.js b/server/api/updates/user/[id].get.js index 7eee274..d5de64a 100644 --- a/server/api/updates/user/[id].get.js +++ b/server/api/updates/user/[id].get.js @@ -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 diff --git a/server/api/upload/image.post.js b/server/api/upload/image.post.js index 797b3a4..9842fa2 100644 --- a/server/api/upload/image.post.js +++ b/server/api/upload/image.post.js @@ -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')}` diff --git a/server/utils/schemas.js b/server/utils/schemas.js new file mode 100644 index 0000000..5278098 --- /dev/null +++ b/server/utils/schemas.js @@ -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() +}) diff --git a/server/utils/validateBody.js b/server/utils/validateBody.js new file mode 100644 index 0000000..63f1f96 --- /dev/null +++ b/server/utils/validateBody.js @@ -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 +} diff --git a/tests/server/api/auth-login.test.js b/tests/server/api/auth-login.test.js index 813d7f8..828240b 100644 --- a/tests/server/api/auth-login.test.js +++ b/tests/server/api/auth-login.test.js @@ -107,7 +107,7 @@ describe('auth login endpoint', () => { await expect(loginHandler(event)).rejects.toMatchObject({ statusCode: 400, - statusMessage: 'Email is required' + statusMessage: 'Validation failed' }) }) }) diff --git a/tests/server/api/validation.test.js b/tests/server/api/validation.test.js new file mode 100644 index 0000000..5d1ac9e --- /dev/null +++ b/tests/server/api/validation.test.js @@ -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') + }) +})