Implement OWASP ASVS L1 security remediation (Phases 0-2)

Auth: Add requireAuth/requireAdmin guards with JWT cookie verification,
member status checks (suspended/cancelled = 403), and admin role
enforcement. Apply to all admin, upload, and payment endpoints. Add
role field to Member model.

CSRF: Double-submit cookie middleware with client plugin. Exempt
webhook and magic-link verify routes.

Headers: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection,
Referrer-Policy, Permissions-Policy on all responses. HSTS and CSP
(Helcim/Cloudinary/Plausible sources) in production only.

Rate limiting: Auth 5/5min, payment 10/min, upload 10/min, general
100/min via rate-limiter-flexible, keyed by client IP.

XSS: DOMPurify sanitization on marked() output with tag/attr
allowlists. escapeHtml() utility for email template interpolation.

Anti-enumeration: Login returns identical response for existing and
non-existing emails. Remove 404 handling from login UI components.

Mass assignment: Remove helcimCustomerId from profile allowedFields.

Session: 7-day token expiry, refresh endpoint, httpOnly+secure cookies.

Environment: Validate required secrets on startup via server plugin.
Remove JWT_SECRET hardcoded fallback.
This commit is contained in:
Jennie Robinson Faber 2026-03-01 12:53:18 +00:00
parent 29c96a207e
commit 26c300c357
41 changed files with 566 additions and 380 deletions

View file

@ -211,9 +211,7 @@ const handleLogin = async () => {
} catch (err) {
console.error("Login error:", err);
if (err.statusCode === 404) {
loginError.value = "No account found";
} else if (err.statusCode === 500) {
if (err.statusCode === 500) {
loginError.value = "Failed to send email";
} else {
loginError.value = "Something went wrong";

View file

@ -167,9 +167,7 @@ const handleLogin = async () => {
} catch (err) {
console.error('Login error:', err)
if (err.statusCode === 404) {
loginError.value = 'No account found with that email. Please check your email or join Ghost Guild.'
} else if (err.statusCode === 500) {
if (err.statusCode === 500) {
loginError.value = 'Failed to send login email. Please try again later.'
} else {
loginError.value = err.statusMessage || 'Something went wrong. Please try again.'

View file

@ -1,12 +1,29 @@
import { marked } from 'marked'
import DOMPurify from 'isomorphic-dompurify'
const ALLOWED_TAGS = [
'a', 'strong', 'em', 'b', 'i', 'u',
'ul', 'ol', 'li', 'p', 'br',
'code', 'pre', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
'del', 'sup', 'sub'
]
const ALLOWED_ATTR = ['href', 'target', 'rel', 'class']
export const useMarkdown = () => {
const render = (markdown) => {
if (!markdown) return ''
return marked(markdown, {
const raw = marked(markdown, {
breaks: true,
gfm: true
})
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false
})
}
return {

View file

@ -1,18 +1,17 @@
export default defineNuxtRouteMiddleware((to) => {
// Skip middleware in server-side rendering to avoid errors
if (process.server) return
// TODO: Temporarily disabled for testing - enable when authentication is set up
// Check if user is authenticated (you'll need to implement proper auth state)
// const isAuthenticated = useCookie('auth-token').value
// if (!isAuthenticated) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// TODO: Add proper role-based authorization
// For now, we assume anyone with a valid token is an admin
})
export default defineNuxtRouteMiddleware(async (to) => {
if (import.meta.server) return
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
if (!isAuthenticated.value) {
await checkMemberStatus()
}
if (!isAuthenticated.value) {
return navigateTo('/')
}
if (memberData.value?.role !== 'admin') {
return navigateTo('/members')
}
})

View file

@ -0,0 +1,23 @@
export default defineNuxtPlugin(() => {
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS'])
globalThis.$fetch = globalThis.$fetch.create({
onRequest({ options }) {
const method = (options.method || 'GET').toUpperCase()
if (safeMethods.has(method)) return
// Read CSRF token from cookie
const csrfToken = useCookie('csrf-token').value
if (csrfToken) {
options.headers = options.headers || {}
if (options.headers instanceof Headers) {
options.headers.set('x-csrf-token', csrfToken)
} else if (Array.isArray(options.headers)) {
options.headers.push(['x-csrf-token', csrfToken])
} else {
options.headers['x-csrf-token'] = csrfToken
}
}
}
})
})

View file

@ -1,7 +1,7 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
devtools: { enabled: process.env.NODE_ENV !== 'production' },
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
build: {
transpile: ["vue-cal"],
@ -14,7 +14,7 @@ export default defineNuxtConfig({
// Private keys (server-side only)
mongodbUri:
process.env.MONGODB_URI || "mongodb://localhost:27017/ghostguild",
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-in-production",
jwtSecret: process.env.JWT_SECRET || "",
resendApiKey: process.env.RESEND_API_KEY || "",
helcimApiToken: process.env.HELCIM_API_TOKEN || "",
slackBotToken: process.env.SLACK_BOT_TOKEN || "",

View file

@ -1,26 +1,13 @@
import Member from '../../models/member.js'
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
import jwt from 'jsonwebtoken'
import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
// Basic auth check
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// jwt.verify(token, config.jwtSecret)
await requireAdmin(event)
await connectDB()
// Get stats
const totalMembers = await Member.countDocuments()
const now = new Date()
@ -28,21 +15,21 @@ export default defineEventHandler(async (event) => {
startDate: { $lte: now },
endDate: { $gte: now }
})
// Calculate monthly revenue from member contributions
const members = await Member.find({}, 'contributionTier').lean()
const monthlyRevenue = members.reduce((total, member) => {
return total + parseInt(member.contributionTier || '0')
}, 0)
const pendingSlackInvites = await Member.countDocuments({ slackInvited: false })
// Get recent members (last 5)
const recentMembers = await Member.find()
.sort({ createdAt: -1 })
.limit(5)
.lean()
// Get upcoming events (next 5)
const upcomingEvents = await Event.find({
startDate: { $gte: now }
@ -50,7 +37,7 @@ export default defineEventHandler(async (event) => {
.sort({ startDate: 1 })
.limit(5)
.lean()
return {
stats: {
totalMembers,
@ -62,9 +49,10 @@ export default defineEventHandler(async (event) => {
upcomingEvents
}
} catch (error) {
if (error.statusCode) throw error
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch dashboard data'
})
}
})
})

View file

@ -1,34 +1,22 @@
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
import jwt from 'jsonwebtoken'
import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
// Basic auth check - you may want to implement proper admin role checking
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// jwt.verify(token, config.jwtSecret)
await requireAdmin(event)
await connectDB()
const events = await Event.find()
.sort({ startDate: 1 })
.lean()
return events
} catch (error) {
if (error.statusCode) throw error
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch events'
})
}
})
})

View file

@ -1,21 +1,10 @@
import Event from "../../models/event.js";
import { connectDB } from "../../utils/mongoose.js";
import jwt from "jsonwebtoken";
import { requireAdmin } from "../../utils/auth.js";
export default defineEventHandler(async (event) => {
try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// const decoded = jwt.verify(token, config.jwtSecret)
const admin = await requireAdmin(event);
const body = await readBody(event);
@ -31,7 +20,7 @@ export default defineEventHandler(async (event) => {
const eventData = {
...body,
createdBy: "admin@ghostguild.org", // TODO: Use actual authenticated user
createdBy: admin.email,
startDate: new Date(body.startDate),
endDate: new Date(body.endDate),
registrationDeadline: body.registrationDeadline
@ -67,6 +56,7 @@ export default defineEventHandler(async (event) => {
return savedEvent;
} catch (error) {
if (error.statusCode) throw error;
console.error("Error creating event:", error);
throw createError({
statusCode: 500,

View file

@ -1,26 +1,15 @@
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
import jwt from 'jsonwebtoken'
import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// const decoded = jwt.verify(token, config.jwtSecret)
await requireAdmin(event)
const eventId = getRouterParam(event, 'id')
await connectDB()
const deletedEvent = await Event.findByIdAndDelete(eventId)
if (!deletedEvent) {
@ -29,13 +18,14 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Event not found'
})
}
return { success: true, message: 'Event deleted successfully' }
} catch (error) {
if (error.statusCode) throw error
console.error('Error deleting event:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to delete event'
})
}
})
})

View file

@ -1,47 +1,31 @@
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
import jwt from 'jsonwebtoken'
import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// const decoded = jwt.verify(token, config.jwtSecret)
await requireAdmin(event)
const eventId = getRouterParam(event, 'id')
console.log('🔍 API: Get event by ID called')
console.log('🔍 API: Event ID param:', eventId)
await connectDB()
const eventData = await Event.findById(eventId)
console.log('🔍 API: Event data found:', eventData ? 'YES' : 'NO')
console.log('🔍 API: Event data preview:', eventData ? { id: eventData._id, title: eventData.title } : null)
if (!eventData) {
console.log('❌ API: Event not found in database')
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
})
}
console.log('✅ API: Returning event data')
return { data: eventData }
} catch (error) {
console.error('❌ API: Error fetching event:', error)
if (error.statusCode) throw error
console.error('Error fetching event:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to fetch event'
})
}
})
})

View file

@ -1,25 +1,14 @@
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
import jwt from 'jsonwebtoken'
import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// const decoded = jwt.verify(token, config.jwtSecret)
await requireAdmin(event)
const eventId = getRouterParam(event, 'id')
const body = await readBody(event)
// Validate required fields
if (!body.title || !body.description || !body.startDate || !body.endDate) {
throw createError({
@ -29,7 +18,7 @@ export default defineEventHandler(async (event) => {
}
await connectDB()
const updateData = {
...body,
startDate: new Date(body.startDate),
@ -37,7 +26,7 @@ export default defineEventHandler(async (event) => {
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
updatedAt: new Date()
}
// Handle ticket data
if (body.tickets) {
updateData.tickets = {
@ -67,13 +56,14 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Event not found'
})
}
return updatedEvent
} catch (error) {
if (error.statusCode) throw error
console.error('Error updating event:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to update event'
})
}
})
})

View file

@ -1,34 +1,22 @@
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import jwt from 'jsonwebtoken'
import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
// Basic auth check - you may want to implement proper admin role checking
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// jwt.verify(token, config.jwtSecret)
await requireAdmin(event)
await connectDB()
const members = await Member.find()
.sort({ createdAt: -1 })
.lean()
return members
} catch (error) {
if (error.statusCode) throw error
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch members'
})
}
})
})

View file

@ -1,24 +1,13 @@
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import jwt from 'jsonwebtoken'
import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// jwt.verify(token, config.jwtSecret)
await requireAdmin(event)
const body = await readBody(event)
// Validate required fields
if (!body.name || !body.email || !body.circle || !body.contributionTier) {
throw createError({
@ -28,7 +17,7 @@ export default defineEventHandler(async (event) => {
}
await connectDB()
// Check if member already exists
const existingMember = await Member.findOne({ email: body.email })
if (existingMember) {
@ -47,14 +36,14 @@ export default defineEventHandler(async (event) => {
})
const savedMember = await newMember.save()
return savedMember
} catch (error) {
if (error.statusCode) throw error
throw createError({
statusCode: 500,
statusMessage: 'Failed to create member'
})
}
})
})

View file

@ -1,9 +1,11 @@
import Series from "../../models/series.js";
import Event from "../../models/event.js";
import { connectDB } from "../../utils/mongoose.js";
import { requireAdmin } from "../../utils/auth.js";
export default defineEventHandler(async (event) => {
try {
await requireAdmin(event);
await connectDB();
// Fetch all series

View file

@ -1,12 +1,14 @@
import Series from '../../models/series.js'
import { connectDB } from '../../utils/mongoose.js'
import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const admin = await requireAdmin(event)
await connectDB()
const body = await readBody(event)
// Validate required fields
if (!body.id || !body.title || !body.description) {
throw createError({
@ -14,7 +16,7 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Series ID, title, and description are required'
})
}
// Create new series
const newSeries = new Series({
id: body.id,
@ -22,7 +24,7 @@ export default defineEventHandler(async (event) => {
description: body.description,
type: body.type || 'workshop_series',
totalEvents: body.totalEvents || null,
createdBy: 'admin' // TODO: Get from authentication
createdBy: admin.email
})
await newSeries.save()

View file

@ -1,9 +1,11 @@
import Series from '../../models/series.js'
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
await requireAdmin(event)
await connectDB()
const body = await readBody(event)

View file

@ -1,9 +1,11 @@
import Series from '../../../models/series.js'
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
await requireAdmin(event)
await connectDB()
const id = getRouterParam(event, 'id')

View file

@ -1,9 +1,11 @@
import Series from '../../../models/series.js'
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
await requireAdmin(event)
await connectDB()
const id = getRouterParam(event, 'id')

View file

@ -1,9 +1,11 @@
import Series from '../../../models/series.js'
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
await requireAdmin(event)
await connectDB()
const body = await readBody(event)

View file

@ -19,19 +19,23 @@ export default defineEventHandler(async (event) => {
});
}
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
const member = await Member.findOne({ email });
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "No account found with that email address",
});
// Return same response shape to prevent enumeration
return {
success: true,
message: GENERIC_MESSAGE,
};
}
// Generate magic link token
const token = jwt.sign(
{ memberId: member._id },
process.env.JWT_SECRET,
{ expiresIn: "15m" }, // Shorter expiry for security
{ expiresIn: "15m" },
);
// Get the base URL for the magic link
@ -65,7 +69,7 @@ export default defineEventHandler(async (event) => {
return {
success: true,
message: "Login link sent to your email",
message: GENERIC_MESSAGE,
};
} catch (error) {
console.error("Failed to send email:", error);

View file

@ -1,60 +1,30 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
import { requireAuth } from "../../utils/auth.js";
export default defineEventHandler(async (event) => {
await connectDB();
const member = await requireAuth(event);
const token = getCookie(event, "auth-token");
console.log("Auth check - token found:", !!token);
if (!token) {
console.log("No auth token found in cookies");
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const member = await Member.findById(decoded.memberId).select("-__v");
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
return {
_id: member._id,
id: member._id,
email: member.email,
name: member.name,
circle: member.circle,
contributionTier: member.contributionTier,
membershipLevel: `${member.circle}-${member.contributionTier}`,
// Profile fields
pronouns: member.pronouns,
timeZone: member.timeZone,
avatar: member.avatar,
studio: member.studio,
bio: member.bio,
location: member.location,
socialLinks: member.socialLinks,
offering: member.offering,
lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory,
privacy: member.privacy,
// Peer support
peerSupport: member.peerSupport,
};
} catch (err) {
console.error("Token verification error:", err);
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
return {
_id: member._id,
id: member._id,
email: member.email,
name: member.name,
role: member.role || 'member',
circle: member.circle,
contributionTier: member.contributionTier,
membershipLevel: `${member.circle}-${member.contributionTier}`,
// Profile fields
pronouns: member.pronouns,
timeZone: member.timeZone,
avatar: member.avatar,
studio: member.studio,
bio: member.bio,
location: member.location,
socialLinks: member.socialLinks,
offering: member.offering,
lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory,
privacy: member.privacy,
// Peer support
peerSupport: member.peerSupport,
};
});

View file

@ -0,0 +1,58 @@
import jwt from 'jsonwebtoken'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await connectDB()
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Not authenticated'
})
}
let decoded
try {
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
}
const member = await Member.findById(decoded.memberId)
if (!member) {
throw createError({
statusCode: 401,
statusMessage: 'Member not found'
})
}
if (member.status === 'suspended' || member.status === 'cancelled') {
throw createError({
statusCode: 403,
statusMessage: 'Account is ' + member.status
})
}
// Issue a fresh token
const newToken = jwt.sign(
{ memberId: member._id, email: member.email },
useRuntimeConfig().jwtSecret,
{ expiresIn: '7d' }
)
setCookie(event, 'auth-token', newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days
})
return { success: true }
})

View file

@ -6,8 +6,6 @@ export default defineEventHandler(async (event) => {
await connectDB()
const token = getCookie(event, 'auth-token')
console.log('🔍 Auth status check - token exists:', !!token)
if (!token) {
return { authenticated: false, member: null }
}
@ -17,11 +15,13 @@ export default defineEventHandler(async (event) => {
const member = await Member.findById(decoded.memberId).select('-__v')
if (!member) {
console.log('⚠️ Token valid but member not found')
return { authenticated: false, member: null }
}
console.log('✅ Auth status check - member found:', member.email)
if (member.status === 'suspended' || member.status === 'cancelled') {
return { authenticated: false, member: null, reason: 'account_' + member.status }
}
return {
authenticated: true,
member: {
@ -34,7 +34,6 @@ export default defineEventHandler(async (event) => {
}
}
} catch (err) {
console.error('❌ Auth status check - token verification failed:', err.message)
return { authenticated: false, member: null }
}
})

View file

@ -33,22 +33,21 @@ export default defineEventHandler(async (event) => {
const sessionToken = jwt.sign(
{ memberId: member._id, email: member.email },
process.env.JWT_SECRET,
{ expiresIn: '30d' }
{ expiresIn: '7d' }
)
// Set the session cookie
setCookie(event, 'auth-token', sessionToken, {
httpOnly: false, // Allow JavaScript access for debugging in development
secure: false, // Don't require HTTPS in development
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 days
maxAge: 60 * 60 * 24 * 7 // 7 days
})
// Redirect to the members dashboard or home page
await sendRedirect(event, '/members', 302)
} catch (err) {
console.error('Token verification error:', err)
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired token'

View file

@ -38,8 +38,6 @@ export default defineEventHandler(async (event) => {
})
}
// Debug: Log token (first few chars only)
console.log('Using Helcim token:', helcimToken.substring(0, 10) + '...')
// Test the connection first with native fetch
try {
@ -55,8 +53,7 @@ export default defineEventHandler(async (event) => {
throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`)
}
const testData = await testResponse.json()
console.log('Connection test passed:', testData)
await testResponse.json()
} catch (testError) {
console.error('Connection test failed:', testError)
throw createError({
@ -113,18 +110,14 @@ export default defineEventHandler(async (event) => {
)
// Set the session cookie server-side
console.log('Setting auth-token cookie for member:', member.email)
console.log('NODE_ENV:', process.env.NODE_ENV)
setCookie(event, 'auth-token', token, {
httpOnly: true, // Server-only for security
secure: false, // Don't require HTTPS in development
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
domain: undefined // Let browser set domain automatically
})
console.log('Cookie set successfully')
return {
success: true,
customerId: customerData.id,

View file

@ -1,13 +1,14 @@
// Initialize HelcimPay.js session
import { requireAuth } from "../../utils/auth.js";
const HELCIM_API_BASE = "https://api.helcim.com/v2";
export default defineEventHandler(async (event) => {
try {
await requireAuth(event);
const config = useRuntimeConfig(event);
const body = await readBody(event);
// Debug log the request body
console.log("Initialize payment request body:", body);
const helcimToken =
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
@ -43,8 +44,6 @@ export default defineEventHandler(async (event) => {
requestBody.orderNumber = `${body.metadata.eventId}`;
}
console.log("Helcim request body:", JSON.stringify(requestBody, null, 2));
// Initialize HelcimPay.js session
const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, {
method: "POST",

View file

@ -3,6 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import { getSlackService } from '../../utils/slack.ts'
import { requireAuth } from '../../utils/auth.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
@ -72,6 +73,7 @@ async function inviteToSlack(member) {
export default defineEventHandler(async (event) => {
try {
await requireAuth(event)
await connectDB()
const config = useRuntimeConfig(event)
const body = await readBody(event)
@ -91,11 +93,8 @@ export default defineEventHandler(async (event) => {
})
}
console.log('Subscription request body:', body)
// Check if payment is required
if (!requiresPayment(body.contributionTier)) {
console.log('No payment required for tier:', body.contributionTier)
// For free tier, just update member status
const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId },
@ -107,8 +106,6 @@ export default defineEventHandler(async (event) => {
{ new: true }
)
console.log('Updated member for free tier:', member)
// Send Slack invitation for free tier members
await inviteToSlack(member)
@ -119,11 +116,8 @@ export default defineEventHandler(async (event) => {
}
}
console.log('Payment required for tier:', body.contributionTier)
// Get the Helcim plan ID
const planId = getHelcimPlanId(body.contributionTier)
console.log('Plan ID for tier:', planId)
// Validate card token is provided
if (!body.cardToken) {
@ -135,8 +129,6 @@ export default defineEventHandler(async (event) => {
// Check if we have a configured plan for this tier
if (!planId) {
console.log('No Helcim plan configured for tier:', body.contributionTier)
const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId },
{
@ -168,8 +160,6 @@ export default defineEventHandler(async (event) => {
// Try to create subscription in Helcim
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Attempting to create Helcim subscription with plan ID:', planId)
// Generate a proper alphanumeric idempotency key (exactly 25 characters)
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let idempotencyKey = ''
@ -197,10 +187,6 @@ export default defineEventHandler(async (event) => {
'idempotency-key': idempotencyKey
}
console.log('Subscription request body:', requestBody)
console.log('Request headers:', requestHeaders)
console.log('Request URL:', `${HELCIM_API_BASE}/subscriptions`)
try {
const subscriptionResponse = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'POST',
@ -210,47 +196,11 @@ export default defineEventHandler(async (event) => {
if (!subscriptionResponse.ok) {
const errorText = await subscriptionResponse.text()
console.error('Subscription creation failed:')
console.error('Status:', subscriptionResponse.status)
console.error('Status Text:', subscriptionResponse.statusText)
console.error('Headers:', Object.fromEntries(subscriptionResponse.headers.entries()))
console.error('Response Body:', errorText)
console.error('Request was:', {
url: `${HELCIM_API_BASE}/subscriptions`,
method: 'POST',
body: requestBody,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken ? 'present' : 'missing'
}
})
console.error('Subscription creation failed:', subscriptionResponse.status)
// If it's a validation error, let's try to get more info about available plans
if (subscriptionResponse.status === 400 || subscriptionResponse.status === 404) {
console.log('Plan might not exist. Trying to get list of available payment plans...')
// Try to fetch available payment plans
try {
const plansResponse = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
if (plansResponse.ok) {
const plansData = await plansResponse.json()
console.log('Available payment plans:', JSON.stringify(plansData, null, 2))
} else {
console.log('Could not fetch payment plans:', plansResponse.status, plansResponse.statusText)
}
} catch (planError) {
console.log('Error fetching payment plans:', planError.message)
}
// For now, just update member status and let user know we need to configure plans
// Plan might not exist -- update member status and proceed
const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId },
{
@ -287,7 +237,6 @@ export default defineEventHandler(async (event) => {
}
const subscriptionData = await subscriptionResponse.json()
console.log('Subscription created successfully:', subscriptionData)
// Extract the first subscription from the response array
const subscription = subscriptionData.data?.[0]

View file

@ -1,8 +1,11 @@
// Update customer billing address
import { requireAuth } from '../../utils/auth.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
await requireAuth(event)
const config = useRuntimeConfig(event)
const body = await readBody(event)

View file

@ -1,11 +1,14 @@
// Verify payment token from HelcimPay.js
import { requireAuth } from '../../utils/auth.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
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({
@ -14,25 +17,57 @@ export default defineEventHandler(async (event) => {
})
}
console.log('Payment verification request:', {
customerId: body.customerId,
cardToken: body.cardToken ? 'present' : 'missing'
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
if (!helcimToken) {
throw createError({
statusCode: 500,
statusMessage: 'Helcim API token not configured'
})
}
// Verify the card token by fetching the customer's cards from Helcim
const response = await fetch(`${HELCIM_API_BASE}/customers/${body.customerId}/cards`, {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
// Since HelcimPay.js already verified the payment and we have the card token,
// we can just return success. The card is already associated with the customer.
console.log('Payment already verified through HelcimPay.js, returning success')
if (!response.ok) {
const errorText = await response.text()
console.error('Payment verification failed:', response.status, errorText)
throw createError({
statusCode: 502,
statusMessage: 'Payment verification failed with Helcim'
})
}
const cards = await response.json()
// Verify the card token exists for this customer
const cardExists = Array.isArray(cards) && cards.some(card =>
card.cardToken === body.cardToken || card.id
)
if (!cardExists && Array.isArray(cards) && cards.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'No payment method found for this customer'
})
}
return {
success: true,
cardToken: body.cardToken,
message: 'Payment verified successfully through HelcimPay.js'
message: 'Payment verified with Helcim'
}
} catch (error) {
console.error('Error verifying payment:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to verify payment'
statusMessage: error.statusMessage || 'Failed to verify payment'
})
}
})
})

View file

@ -46,9 +46,11 @@ export default defineEventHandler(async (event) => {
// Search by name or bio
if (search) {
// Escape special regex characters to prevent ReDoS
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
dbQuery.$or = [
{ name: { $regex: search, $options: "i" } },
{ bio: { $regex: search, $options: "i" } },
{ name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } },
];
}
@ -60,11 +62,12 @@ export default defineEventHandler(async (event) => {
];
// If search is also present, combine with AND
if (search) {
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
dbQuery.$and = [
{
$or: [
{ name: { $regex: search, $options: "i" } },
{ bio: { $regex: search, $options: "i" } },
{ name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: escaped, $options: "i" } },
],
},
{

View file

@ -1,29 +1,9 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
import { requireAuth } from "../../utils/auth.js";
export default defineEventHandler(async (event) => {
await connectDB();
const token = getCookie(event, "auth-token");
if (!token) {
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
memberId = decoded.memberId;
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
const authedMember = await requireAuth(event);
const memberId = authedMember._id;
const body = await readBody(event);
@ -37,7 +17,6 @@ export default defineEventHandler(async (event) => {
"location",
"socialLinks",
"showInDirectory",
"helcimCustomerId",
];
// Define privacy fields

View file

@ -1,4 +1,5 @@
import { v2 as cloudinary } from 'cloudinary'
import { requireAuth } from '../../utils/auth.js'
// Configure Cloudinary
cloudinary.config({
@ -9,6 +10,7 @@ cloudinary.config({
export default defineEventHandler(async (event) => {
try {
await requireAuth(event)
// Parse the multipart form data
const formData = await readMultipartFormData(event)

View file

@ -0,0 +1,46 @@
import crypto from 'crypto'
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
// Routes exempt from CSRF (external webhooks, magic link verify)
const EXEMPT_PREFIXES = [
'/api/helcim/webhook',
'/api/slack/webhook',
'/api/auth/verify',
]
function isExempt(path) {
return EXEMPT_PREFIXES.some(prefix => path.startsWith(prefix))
}
export default defineEventHandler((event) => {
const method = getMethod(event)
const path = getRequestURL(event).pathname
// Always set a CSRF token cookie if one doesn't exist
let csrfToken = getCookie(event, 'csrf-token')
if (!csrfToken) {
csrfToken = crypto.randomBytes(32).toString('hex')
setCookie(event, 'csrf-token', csrfToken, {
httpOnly: false, // Must be readable by JS to include in requests
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
})
}
// Only check state-changing methods
if (SAFE_METHODS.has(method)) return
if (!path.startsWith('/api/')) return
if (isExempt(path)) return
// Double-submit cookie check: header must match cookie
const headerToken = getHeader(event, 'x-csrf-token')
if (!headerToken || headerToken !== csrfToken) {
throw createError({
statusCode: 403,
statusMessage: 'CSRF token missing or invalid'
})
}
})

View file

@ -0,0 +1,30 @@
export default defineEventHandler((event) => {
const headers = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '0',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
}
if (process.env.NODE_ENV === 'production') {
headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
// CSP: allow self, Cloudinary images, HelcimPay.js, Plausible analytics
headers['Content-Security-Policy'] = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://myposjs.helcim.com https://plausible.io",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://res.cloudinary.com https://*.cloudinary.com",
"font-src 'self'",
"connect-src 'self' https://api.helcim.com https://myposjs.helcim.com https://plausible.io",
"frame-src 'self' https://myposjs.helcim.com https://secure.helcim.com",
"base-uri 'self'",
"form-action 'self'",
].join('; ')
}
for (const [key, value] of Object.entries(headers)) {
setHeader(event, key, value)
}
})

View file

@ -0,0 +1,65 @@
import { RateLimiterMemory } from 'rate-limiter-flexible'
// Strict rate limit for auth endpoints
const authLimiter = new RateLimiterMemory({
points: 5, // 5 requests
duration: 300, // per 5 minutes
keyPrefix: 'rl_auth'
})
// Moderate rate limit for payment endpoints
const paymentLimiter = new RateLimiterMemory({
points: 10,
duration: 60,
keyPrefix: 'rl_payment'
})
// Light rate limit for upload endpoints
const uploadLimiter = new RateLimiterMemory({
points: 10,
duration: 60,
keyPrefix: 'rl_upload'
})
// General API rate limit
const generalLimiter = new RateLimiterMemory({
points: 100,
duration: 60,
keyPrefix: 'rl_general'
})
function getClientIp(event) {
return getHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
|| getHeader(event, 'x-real-ip')
|| event.node.req.socket.remoteAddress
|| 'unknown'
}
const AUTH_PATHS = new Set(['/api/auth/login'])
const PAYMENT_PREFIXES = ['/api/helcim/']
const UPLOAD_PATHS = new Set(['/api/upload/image'])
export default defineEventHandler(async (event) => {
const path = getRequestURL(event).pathname
if (!path.startsWith('/api/')) return
const ip = getClientIp(event)
try {
if (AUTH_PATHS.has(path)) {
await authLimiter.consume(ip)
} else if (PAYMENT_PREFIXES.some(p => path.startsWith(p))) {
await paymentLimiter.consume(ip)
} else if (UPLOAD_PATHS.has(path)) {
await uploadLimiter.consume(ip)
} else {
await generalLimiter.consume(ip)
}
} catch (rateLimiterRes) {
setHeader(event, 'Retry-After', Math.ceil(rateLimiterRes.msBeforeNext / 1000))
throw createError({
statusCode: 429,
statusMessage: 'Too many requests. Please try again later.'
})
}
})

View file

@ -22,6 +22,11 @@ const memberSchema = new mongoose.Schema({
enum: getValidContributionValues(),
required: true,
},
role: {
type: String,
enum: ["member", "admin"],
default: "member",
},
status: {
type: String,
enum: ["pending_payment", "active", "suspended", "cancelled"],

View file

@ -0,0 +1,9 @@
export default defineNitroPlugin(() => {
const config = useRuntimeConfig()
if (!config.jwtSecret) {
console.error('FATAL: JWT_SECRET environment variable is not set. Server cannot start without it.')
console.error('Set JWT_SECRET in your .env file or environment variables.')
process.exit(1)
}
})

65
server/utils/auth.js Normal file
View file

@ -0,0 +1,65 @@
import jwt from 'jsonwebtoken'
import Member from '../models/member.js'
import { connectDB } from './mongoose.js'
/**
* Verify JWT from cookie and return the decoded member.
* Throws 401 if token is missing or invalid.
*/
export async function requireAuth(event) {
await connectDB()
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
let decoded
try {
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
}
const member = await Member.findById(decoded.memberId)
if (!member) {
throw createError({
statusCode: 401,
statusMessage: 'Member not found'
})
}
if (member.status === 'suspended' || member.status === 'cancelled') {
throw createError({
statusCode: 403,
statusMessage: 'Account is ' + member.status
})
}
return member
}
/**
* Verify JWT and require admin role.
* Throws 401 if not authenticated, 403 if not admin.
*/
export async function requireAdmin(event) {
const member = await requireAuth(event)
if (member.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin access required'
})
}
return member
}

View file

@ -0,0 +1,18 @@
const ESCAPE_MAP = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
const ESCAPE_RE = /[&<>"']/g
/**
* Escape HTML special characters to prevent XSS in email templates.
* Returns empty string for null/undefined input.
*/
export function escapeHtml(str) {
if (str == null) return ''
return String(str).replace(ESCAPE_RE, (ch) => ESCAPE_MAP[ch])
}

View file

@ -1,4 +1,5 @@
import { Resend } from "resend";
import { escapeHtml } from "./escapeHtml.js";
const resend = new Resend(process.env.RESEND_API_KEY);
@ -33,7 +34,7 @@ export async function sendEventRegistrationEmail(registration, eventData) {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
to: [registration.email],
subject: `You're registered for ${eventData.title}`,
subject: `You're registered for ${escapeHtml(eventData.title)}`,
html: `
<!DOCTYPE html>
<html>
@ -105,9 +106,9 @@ export async function sendEventRegistrationEmail(registration, eventData) {
</div>
<div class="content">
<p>Hi ${registration.name},</p>
<p>Hi ${escapeHtml(registration.name)},</p>
<p>Thank you for registering for <strong>${eventData.title}</strong>!</p>
<p>Thank you for registering for <strong>${escapeHtml(eventData.title)}</strong>!</p>
<div class="event-details">
<div class="detail-row">
@ -122,11 +123,11 @@ export async function sendEventRegistrationEmail(registration, eventData) {
<div class="detail-row">
<div class="label">Location</div>
<div class="value">${eventData.location}</div>
<div class="value">${escapeHtml(eventData.location)}</div>
</div>
</div>
${eventData.description ? `<p>${eventData.description}</p>` : ""}
${eventData.description ? `<p>${escapeHtml(eventData.description)}</p>` : ""}
${
registration.ticketType &&
@ -148,7 +149,7 @@ export async function sendEventRegistrationEmail(registration, eventData) {
? `
<div class="detail-row">
<div class="label">Transaction ID</div>
<div class="value" style="font-size: 12px; font-family: monospace;">${registration.paymentId}</div>
<div class="value" style="font-size: 12px; font-family: monospace;">${escapeHtml(registration.paymentId)}</div>
</div>
`
: ""
@ -211,7 +212,7 @@ export async function sendEventCancellationEmail(registration, eventData) {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@ghostguild.org>",
to: [registration.email],
subject: `Registration cancelled: ${eventData.title}`,
subject: `Registration cancelled: ${escapeHtml(eventData.title)}`,
html: `
<!DOCTYPE html>
<html>
@ -264,9 +265,9 @@ export async function sendEventCancellationEmail(registration, eventData) {
</div>
<div class="content">
<p>Hi ${registration.name},</p>
<p>Hi ${escapeHtml(registration.name)},</p>
<p>Your registration for <strong>${eventData.title}</strong> has been cancelled.</p>
<p>Your registration for <strong>${escapeHtml(eventData.title)}</strong> has been cancelled.</p>
<p>We're sorry you can't make it. You can always register again if your plans change.</p>
@ -332,7 +333,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@ghostguild.org>",
to: [waitlistEntry.email],
subject: `A spot opened up for ${eventData.title}!`,
subject: `A spot opened up for ${escapeHtml(eventData.title)}!`,
html: `
<!DOCTYPE html>
<html>
@ -413,9 +414,9 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
</div>
<div class="content">
<p>Hi ${waitlistEntry.name},</p>
<p>Hi ${escapeHtml(waitlistEntry.name)},</p>
<p>Great news! A spot has become available for <strong>${eventData.title}</strong>, and you're on the waitlist.</p>
<p>Great news! A spot has become available for <strong>${escapeHtml(eventData.title)}</strong>, and you're on the waitlist.</p>
<div class="urgent">
<p style="margin: 0; font-weight: 600; color: #92400e;">
@ -426,7 +427,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
<div class="event-details">
<div class="detail-row">
<div class="label">Event</div>
<div class="value">${eventData.title}</div>
<div class="value">${escapeHtml(eventData.title)}</div>
</div>
<div class="detail-row">
@ -441,7 +442,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
<div class="detail-row">
<div class="label">Location</div>
<div class="value">${eventData.location}</div>
<div class="value">${escapeHtml(eventData.location)}</div>
</div>
</div>
@ -529,7 +530,7 @@ export async function sendSeriesPassConfirmation(options) {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
to: [to],
subject: `Your Series Pass for ${series.title}`,
subject: `Your Series Pass for ${escapeHtml(series.title)}`,
html: `
<!DOCTYPE html>
<html>
@ -620,10 +621,10 @@ export async function sendSeriesPassConfirmation(options) {
</div>
<div class="content">
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${name},</p>
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${escapeHtml(name)},</p>
<p>
Great news! Your series pass for <strong>${series.title}</strong> is confirmed.
Great news! Your series pass for <strong>${escapeHtml(series.title)}</strong> is confirmed.
You're now registered for all ${events.length} events in this ${seriesTypeLabels[series.type] || "series"}.
</p>
@ -647,7 +648,7 @@ export async function sendSeriesPassConfirmation(options) {
<div class="detail-row">
<div class="label">Series</div>
<div class="value">${series.title}</div>
<div class="value">${escapeHtml(series.title)}</div>
</div>
${
@ -655,7 +656,7 @@ export async function sendSeriesPassConfirmation(options) {
? `
<div class="detail-row">
<div class="label">About</div>
<div class="value">${series.description}</div>
<div class="value">${escapeHtml(series.description)}</div>
</div>
`
: ""
@ -676,7 +677,7 @@ export async function sendSeriesPassConfirmation(options) {
? `
<div class="detail-row">
<div class="label">Transaction ID</div>
<div class="value" style="font-family: monospace; font-size: 14px;">${paymentId}</div>
<div class="value" style="font-family: monospace; font-size: 14px;">${escapeHtml(paymentId)}</div>
</div>
`
: ""
@ -699,7 +700,7 @@ export async function sendSeriesPassConfirmation(options) {
(event, index) => `
<div class="event-item">
<div style="font-weight: 600; color: #7c3aed; margin-bottom: 5px;">
Event ${index + 1}: ${event.title}
Event ${index + 1}: ${escapeHtml(event.title)}
</div>
<div style="font-size: 14px; color: #666; margin: 5px 0;">
📅 ${formatDate(event.startDate)}
@ -708,7 +709,7 @@ export async function sendSeriesPassConfirmation(options) {
🕐 ${formatTime(event.startDate, event.endDate)}
</div>
<div style="font-size: 14px; color: #666; margin: 5px 0;">
📍 ${event.location}
📍 ${escapeHtml(event.location)}
</div>
</div>
`,