Day-of-launch deep-dive audit and remediation. 11 issues fixed across security, correctness, and reliability. Tests: 698 → 758 passing (+60), 0 failing, 2 skipped. CRITICAL (security) Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead useHelcim.js deleted. Production token MUST BE ROTATED post-deploy (was previously exposed in window.__NUXT__ payload). Fix #2 — /api/helcim/customer gated with origin check + per-IP/email rate limit + magic-link email verification (replaces unauthenticated setAuthCookie). Adds payment-bridge token for paid-tier signup so users can complete Helcim checkout before email verify. New utils: server/utils/{magicLink,rateLimit}.js. UX: signup success copy now prompts user to check email. Fix #3 — /api/events/[id]/payment deleted (dead code with unauth member-spoof bypass — processHelcimPayment was a permanent stub). Removes processHelcimPayment export and eventPaymentSchema. Fix #4 — /api/helcim/initialize-payment re-derives ticket amount server-side via calculateTicketPrice and calculateSeriesTicketPrice. Adds new series_ticket metadata type (was being shoved through event_ticket with seriesId in metadata.eventId). Fix #5 — /api/helcim/customer upgrades existing status:guest members in place rather than rejecting with 409. Lowercases email at lookup; preserves _id so prior event registrations stay linked. HIGH (correctness / reliability) Fix #6 — Daily reconciliation cron via Netlify scheduled function (@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs, server/api/internal/reconcile-payments.post.js. Shared-secret auth via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff on Helcim transactions API. Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist endpoints) to dodge legacy location validators. Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest Member when caller is unauthenticated, mirrors event-ticket flow byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and client auth refresh on signedIn:true response. Fix #9 — /api/members/cancel-subscription leaves status active per ratified bylaws (was pending_payment). Adds lastCancelledAt audit field on Member model. Indirectly fixes false-positive detectStuckPendingPayment admin alert for cancelled members. Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema (verifyMagicLinkSchema, max 2000 chars). Fix #11 — 8 vitest cases for cancel-subscription handler (was uncovered). Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md. LAUNCH_READINESS.md updated with new test count, 3 deploy-time tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify Netlify scheduled function), and Fixed-2026-04-25 fix log.
172 lines
4.3 KiB
JavaScript
172 lines
4.3 KiB
JavaScript
import jwt from 'jsonwebtoken'
|
|
import Member from '../models/member.js'
|
|
import { connectDB } from './mongoose.js'
|
|
|
|
/**
|
|
* Issue a session JWT and set the auth-token cookie for the given member.
|
|
* Mirrors the cookie options used by verify.post.js.
|
|
*/
|
|
export function setAuthCookie(event, member) {
|
|
const token = jwt.sign(
|
|
{ memberId: member._id.toString(), email: member.email, tv: member.tokenVersion || 0 },
|
|
useRuntimeConfig(event).jwtSecret,
|
|
{ expiresIn: '7d' }
|
|
)
|
|
|
|
setCookie(event, 'auth-token', token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: 60 * 60 * 24 * 7
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Issue a 30-minute payment-bridge cookie scoped to membership-signup checkout.
|
|
*
|
|
* The signup flow (POST /api/helcim/customer) defers the full session cookie
|
|
* to email-verify (magic link). For paid tiers the user still needs to complete
|
|
* Helcim checkout in the same browser tab — this short-lived, payment-only
|
|
* token lets `/api/helcim/initialize-payment` accept the call without a full
|
|
* session. The cookie is NOT honored by requireAuth and grants nothing else.
|
|
*/
|
|
export function setPaymentBridgeCookie(event, member) {
|
|
const token = jwt.sign(
|
|
{
|
|
memberId: member._id.toString(),
|
|
email: member.email,
|
|
scope: 'payment_bridge'
|
|
},
|
|
useRuntimeConfig(event).jwtSecret,
|
|
{ expiresIn: '30m' }
|
|
)
|
|
|
|
setCookie(event, 'payment-bridge', token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: 60 * 30
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Verify a payment-bridge cookie and return the associated Member, or null.
|
|
* Used by /api/helcim/initialize-payment to allow the membership-signup
|
|
* checkout to proceed before email verification.
|
|
*/
|
|
export async function getPaymentBridgeMember(event) {
|
|
const token = getCookie(event, 'payment-bridge')
|
|
if (!token) return null
|
|
|
|
let decoded
|
|
try {
|
|
decoded = jwt.verify(token, useRuntimeConfig(event).jwtSecret)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
if (decoded.scope !== 'payment_bridge') return null
|
|
|
|
await connectDB()
|
|
const member = await Member.findById(decoded.memberId)
|
|
if (!member) return null
|
|
return member
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
})
|
|
}
|
|
|
|
// Verify session has not been revoked (tokenVersion incremented on logout)
|
|
if (decoded.tv !== member.tokenVersion) {
|
|
throw createError({
|
|
statusCode: 401,
|
|
statusMessage: 'Session has been revoked'
|
|
})
|
|
}
|
|
|
|
return member
|
|
}
|
|
|
|
/**
|
|
* Return the signed-in Member without throwing.
|
|
* Returns null when no cookie, invalid token, or member not found/inactive.
|
|
* Mirrors the non-throwing JWT check in api/auth/status.get.js.
|
|
*/
|
|
export async function getOptionalMember(event) {
|
|
await connectDB()
|
|
|
|
const token = getCookie(event, 'auth-token')
|
|
if (!token) return null
|
|
|
|
let decoded
|
|
try {
|
|
decoded = jwt.verify(token, useRuntimeConfig(event).jwtSecret)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
const member = await Member.findById(decoded.memberId)
|
|
if (!member) return null
|
|
if (member.status === 'suspended' || member.status === 'cancelled') return null
|
|
if (decoded.tv !== member.tokenVersion) return null
|
|
|
|
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
|
|
}
|