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.
433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
import * as z from 'zod'
|
|
import { ADMIN_ALERT_TYPES } from '../models/adminAlertDismissal.js'
|
|
|
|
export const emailSchema = z.object({
|
|
email: z.string().trim().toLowerCase().email()
|
|
})
|
|
|
|
export const verifyMagicLinkSchema = z.object({
|
|
token: z.string().min(1).max(2000)
|
|
}).strict()
|
|
|
|
export const memberCreateSchema = z.object({
|
|
email: z.string().trim().toLowerCase().email(),
|
|
name: z.string().min(1).max(200),
|
|
circle: z.enum(['community', 'founder', 'practitioner']),
|
|
contributionAmount: z.number().int().min(0)
|
|
})
|
|
|
|
export const memberProfileUpdateSchema = z.object({
|
|
pronouns: z.string().max(100).optional(),
|
|
timeZone: z.string().max(100).optional(),
|
|
avatar: z.string().max(500).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(),
|
|
showInDirectory: z.boolean().optional(),
|
|
notifications: z.object({
|
|
events: z.boolean().optional()
|
|
}).optional(),
|
|
craftTags: z.array(z.string().max(100)).max(16).optional(),
|
|
boardSlackHandle: z.string().max(200).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 paymentVerifySchema = z.object({
|
|
cardToken: z.string().min(1),
|
|
customerId: z.union([z.string(), z.number()]).transform(String)
|
|
})
|
|
|
|
// --- Helcim schemas ---
|
|
|
|
export const helcimCreatePlanSchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
amount: z.union([z.string().min(1), z.number().positive()]),
|
|
frequency: z.string().min(1).max(50),
|
|
currency: z.string().max(10).optional()
|
|
})
|
|
|
|
export const helcimCustomerSchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
email: z.string().trim().toLowerCase().email(),
|
|
circle: z.enum(['community', 'founder', 'practitioner']).optional(),
|
|
contributionAmount: z.number().int().min(0).optional(),
|
|
agreedToGuidelines: z.literal(true)
|
|
})
|
|
|
|
export const helcimInitializePaymentSchema = z.object({
|
|
// amount is accepted but IGNORED for ticket types (server re-derives).
|
|
// Kept for verify-mode (subscription card-on-file) where 0 is sent.
|
|
amount: z.number().min(0).optional(),
|
|
customerCode: z.string().max(200).optional(),
|
|
metadata: z.object({
|
|
type: z.enum(['event_ticket', 'series_ticket', 'subscription', 'card_verify', 'membership_signup']).optional(),
|
|
eventTitle: z.string().max(500).optional(),
|
|
eventId: z.string().max(200).optional(),
|
|
seriesId: z.string().max(200).optional(),
|
|
email: z.string().trim().toLowerCase().email().optional()
|
|
}).optional()
|
|
})
|
|
|
|
export const helcimSubscriptionSchema = z.object({
|
|
customerId: z.union([z.string().min(1), z.number()]),
|
|
contributionAmount: z.number().int().min(0),
|
|
customerCode: z.union([z.string().min(1).max(200), z.number()]).transform(String),
|
|
cardToken: z.string().max(500).optional().nullable(),
|
|
cadence: z.enum(['monthly', 'annual']).default('monthly')
|
|
})
|
|
|
|
export const helcimUpdateCardSchema = z.object({
|
|
cardToken: z.string().min(1).max(500)
|
|
})
|
|
|
|
export const helcimUpdateBillingSchema = z.object({
|
|
customerId: z.union([z.string().min(1), z.number()]),
|
|
billingAddress: z.object({
|
|
name: z.string().max(200).optional(),
|
|
street: z.string().min(1).max(500),
|
|
city: z.string().min(1).max(200),
|
|
province: z.string().max(200).optional(),
|
|
state: z.string().max(200).optional(),
|
|
country: z.string().min(1).max(100),
|
|
postalCode: z.string().min(1).max(20)
|
|
})
|
|
})
|
|
|
|
// --- Event ticket/registration schemas ---
|
|
|
|
export const ticketPurchaseSchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
email: z.string().trim().toLowerCase().email(),
|
|
transactionId: z.string().max(500).optional(),
|
|
createAccount: z.boolean().optional().default(true)
|
|
})
|
|
|
|
export const ticketReserveSchema = z.object({
|
|
email: z.string().trim().toLowerCase().email()
|
|
})
|
|
|
|
export const ticketEligibilitySchema = z.object({
|
|
email: z.string().trim().toLowerCase().email()
|
|
})
|
|
|
|
export const waitlistSchema = z.object({
|
|
name: z.string().max(200).optional(),
|
|
email: z.string().trim().toLowerCase().email(),
|
|
membershipLevel: z.string().max(100).optional()
|
|
})
|
|
|
|
export const waitlistDeleteSchema = z.object({
|
|
email: z.string().trim().toLowerCase().email()
|
|
})
|
|
|
|
export const cancelRegistrationSchema = z.object({
|
|
email: z.string().trim().toLowerCase().email()
|
|
})
|
|
|
|
export const checkRegistrationSchema = z.object({
|
|
email: z.string().trim().toLowerCase().email()
|
|
})
|
|
|
|
// --- Member schemas ---
|
|
|
|
export const updateContributionSchema = z.object({
|
|
contributionAmount: z.number().int().min(0),
|
|
cadence: z.enum(['monthly', 'annual']).default('monthly')
|
|
})
|
|
|
|
export const updateCircleSchema = z.object({
|
|
circle: z.enum(['community', 'founder', 'practitioner'])
|
|
})
|
|
|
|
// --- Series ticket schemas ---
|
|
|
|
export const seriesTicketPurchaseSchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
email: z.string().trim().toLowerCase().email(),
|
|
paymentId: z.string().max(500).optional(),
|
|
ticketType: z.enum(['member', 'public', 'guest']),
|
|
})
|
|
|
|
export const seriesTicketEligibilitySchema = z.object({
|
|
email: z.string().trim().toLowerCase().email()
|
|
})
|
|
|
|
// --- Admin schemas ---
|
|
|
|
const emptyStringToUndefined = (v) => (v === '' ? undefined : v)
|
|
|
|
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.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
|
|
membersOnly: z.boolean().optional(),
|
|
registrationDeadline: z.preprocess(emptyStringToUndefined, z.string().optional().nullable()),
|
|
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.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
|
|
earlyBirdPrice: z.preprocess(emptyStringToUndefined, z.number().min(0).optional().nullable()),
|
|
earlyBirdDeadline: z.preprocess(emptyStringToUndefined, z.string().optional().nullable())
|
|
}).optional()
|
|
}).optional(),
|
|
image: z.string().url().optional().nullable(),
|
|
category: z.string().max(100).optional(),
|
|
tags: z.array(z.string().max(100)).max(20).optional(),
|
|
series: z.any().optional()
|
|
}).passthrough()
|
|
|
|
export const adminEventUpdateSchema = 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.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
|
|
membersOnly: z.boolean().optional(),
|
|
registrationDeadline: z.preprocess(emptyStringToUndefined, z.string().optional().nullable()),
|
|
pricing: z.object({
|
|
paymentRequired: z.boolean().optional(),
|
|
isFree: z.boolean().optional(),
|
|
publicPrice: z.number().min(0).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.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
|
|
sold: z.number().int().min(0).optional(),
|
|
earlyBirdPrice: z.preprocess(emptyStringToUndefined, z.number().min(0).optional().nullable()),
|
|
earlyBirdDeadline: z.preprocess(emptyStringToUndefined, z.string().optional().nullable())
|
|
}).optional()
|
|
}).optional(),
|
|
image: z.string().url().optional().nullable(),
|
|
category: z.string().max(100).optional(),
|
|
tags: z.array(z.string().max(100)).max(20).optional(),
|
|
series: z.any().optional(),
|
|
slug: z.string().max(500).optional()
|
|
}).passthrough()
|
|
|
|
export const adminSeriesCreateSchema = z.object({
|
|
id: z.string().min(1).max(200),
|
|
title: z.string().min(1).max(500),
|
|
description: z.string().min(1).max(50000),
|
|
type: z.string().max(100).optional(),
|
|
totalEvents: z.number().int().positive().optional().nullable()
|
|
})
|
|
|
|
export const adminSeriesUpdateSchema = z.object({
|
|
id: z.string().min(1).max(200),
|
|
title: z.string().min(1).max(500),
|
|
description: z.string().max(50000).optional(),
|
|
type: z.string().max(100).optional(),
|
|
totalEvents: z.number().int().positive().optional().nullable()
|
|
})
|
|
|
|
export const adminSeriesItemUpdateSchema = z.object({
|
|
title: z.string().min(1).max(500).optional(),
|
|
description: z.string().max(50000).optional(),
|
|
type: z.string().max(100).optional(),
|
|
totalEvents: z.number().int().positive().optional().nullable(),
|
|
isActive: z.boolean().optional()
|
|
})
|
|
|
|
export const adminSeriesTicketsSchema = z.object({
|
|
id: z.string().min(1).max(200),
|
|
tickets: z.object({
|
|
enabled: z.boolean().optional(),
|
|
requiresSeriesTicket: z.boolean().optional(),
|
|
allowIndividualEventTickets: z.boolean().optional(),
|
|
currency: z.string().max(10).optional(),
|
|
member: z.object({
|
|
available: z.boolean().optional(),
|
|
isFree: z.boolean().optional(),
|
|
price: z.number().min(0).optional(),
|
|
name: z.string().max(200).optional(),
|
|
description: z.string().max(2000).optional(),
|
|
circleOverrides: z.record(z.any()).optional()
|
|
}).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().nullable(),
|
|
sold: z.number().int().min(0).optional(),
|
|
reserved: z.number().int().min(0).optional(),
|
|
earlyBirdPrice: z.number().min(0).optional().nullable(),
|
|
earlyBirdDeadline: z.string().optional().nullable()
|
|
}).optional(),
|
|
capacity: z.object({
|
|
total: z.number().int().positive().optional().nullable(),
|
|
reserved: z.number().int().min(0).optional()
|
|
}).optional(),
|
|
waitlist: z.object({
|
|
enabled: z.boolean().optional(),
|
|
maxSize: z.number().int().positive().optional().nullable(),
|
|
entries: z.array(z.any()).optional()
|
|
}).optional()
|
|
})
|
|
})
|
|
|
|
export const adminMemberCreateSchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
email: z.string().trim().toLowerCase().email(),
|
|
circle: z.enum(['community', 'founder', 'practitioner']),
|
|
contributionAmount: z.number().int().min(0)
|
|
})
|
|
|
|
export const adminMemberUpdateSchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
email: z.string().trim().toLowerCase().email(),
|
|
circle: z.enum(['community', 'founder', 'practitioner']),
|
|
contributionAmount: z.number().int().min(0),
|
|
status: z.enum(['pending_payment', 'active', 'suspended', 'cancelled'])
|
|
})
|
|
|
|
export const adminRoleUpdateSchema = z.object({
|
|
role: z.enum(['admin', 'member'])
|
|
})
|
|
|
|
export const bulkMemberImportSchema = z.object({
|
|
members: z.array(z.object({
|
|
name: z.string().min(1).max(200),
|
|
email: z.string().trim().toLowerCase().email(),
|
|
circle: z.enum(['community', 'founder', 'practitioner']),
|
|
contributionAmount: z.number().int().min(0)
|
|
})).min(1).max(100)
|
|
})
|
|
|
|
export const memberInviteSchema = z.object({
|
|
memberIds: z.array(z.string().min(1)).min(1).max(100),
|
|
emailTemplate: z.string().min(1).max(10000)
|
|
})
|
|
|
|
// --- Pre-registrant schemas ---
|
|
|
|
export const preRegistrantStatusUpdateSchema = z.object({
|
|
status: z.enum(['pending', 'selected']),
|
|
adminNotes: z.string().max(2000).optional()
|
|
})
|
|
|
|
export const preRegistrantBulkStatusSchema = z.object({
|
|
ids: z.array(z.string().min(1)).min(1).max(100),
|
|
status: z.enum(['pending', 'selected'])
|
|
})
|
|
|
|
export const preRegistrantInviteSchema = z.object({
|
|
preRegistrantIds: z.array(z.string().min(1)).min(1).max(20),
|
|
emailTemplate: z.string().min(1).max(10000)
|
|
})
|
|
|
|
export const inviteVerifySchema = z.object({
|
|
token: z.string().min(1)
|
|
})
|
|
|
|
export const inviteAcceptSchema = z.object({
|
|
preRegistrationId: z.string().min(1),
|
|
name: z.string().min(1).max(200),
|
|
pronouns: z.string().max(100).optional(),
|
|
location: z.string().max(200).optional(),
|
|
circle: z.enum(['community', 'founder', 'practitioner']),
|
|
motivation: z.string().max(5000).optional(),
|
|
contributionAmount: z.number().int().min(0),
|
|
agreedToGuidelines: z.literal(true),
|
|
token: z.string().min(1)
|
|
})
|
|
|
|
// --- Onboarding schemas ---
|
|
|
|
export const onboardingTrackSchema = z.object({
|
|
goal: z.enum(['eventPageVisited', 'boardPageVisited', 'wikiClicked']).optional(),
|
|
skip: z.enum(['profileTags', 'visitEvent', 'board', 'wiki']).optional(),
|
|
}).refine((v) => v.goal || v.skip, {
|
|
message: 'Must provide goal or skip',
|
|
})
|
|
|
|
// --- Tag schemas ---
|
|
|
|
export const tagSuggestionSchema = z.object({
|
|
label: z.string().min(1).max(100),
|
|
pool: z.enum(['craft', 'cooperative'])
|
|
})
|
|
|
|
// --- Board post / channel schemas ---
|
|
|
|
export const boardPostCreateSchema = z.object({
|
|
title: z.string().trim().min(1).max(120),
|
|
seeking: z.string().max(500).optional(),
|
|
offering: z.string().max(500).optional(),
|
|
note: z.string().max(300).optional(),
|
|
tags: z.array(z.string().max(100)).optional().default([])
|
|
}).refine(
|
|
(data) => (data.seeking || '').trim().length > 0 || (data.offering || '').trim().length > 0,
|
|
{ message: 'At least one of seeking or offering must be provided', path: ['seeking'] }
|
|
)
|
|
|
|
export const boardPostUpdateSchema = z.object({
|
|
title: z.string().trim().min(1).max(120).optional(),
|
|
seeking: z.string().max(500).optional(),
|
|
offering: z.string().max(500).optional(),
|
|
note: z.string().max(300).optional(),
|
|
tags: z.array(z.string().max(100)).optional()
|
|
})
|
|
|
|
export const boardChannelCreateSchema = z.object({
|
|
name: z.string().trim().min(1).max(200),
|
|
slackChannelId: z.string().trim().min(1).max(50).regex(/^[A-Z0-9]+$/, 'Invalid Slack channel ID').optional(),
|
|
tagSlugs: z.array(z.string().max(100)).optional().default([])
|
|
})
|
|
|
|
export const boardChannelUpdateSchema = z.object({
|
|
name: z.string().trim().min(1).max(200).optional(),
|
|
slackChannelId: z.string().trim().min(1).max(50).regex(/^[A-Z0-9]+$/, 'Invalid Slack channel ID').optional(),
|
|
tagSlugs: z.array(z.string().max(100)).optional()
|
|
})
|
|
|
|
// --- Admin alert schemas ---
|
|
|
|
export const adminAlertDismissSchema = z.object({
|
|
alertType: z.enum(ADMIN_ALERT_TYPES),
|
|
signature: z.string().min(1).max(128)
|
|
})
|
|
|
|
export const adminAlertRestoreSchema = z.object({
|
|
alertTypes: z
|
|
.array(z.enum(ADMIN_ALERT_TYPES))
|
|
.min(1)
|
|
.max(ADMIN_ALERT_TYPES.length)
|
|
})
|
|
|
|
// --- Site content (key/value editable copy blocks) ---
|
|
|
|
export const SITE_CONTENT_KEYS = ['homepage.wiki_feature']
|
|
|
|
export const siteContentUpsertSchema = z.object({
|
|
title: z.string().max(300).optional(),
|
|
body: z.string().max(5000).optional()
|
|
})
|