ghostguild-org/server/models/member.js
Jennie Robinson Faber 208638e374 feat(launch): security and correctness fixes for 2026-05-01 launch
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.
2026-04-25 18:42:36 +01:00

139 lines
3.6 KiB
JavaScript

// server/models/member.js
import mongoose from "mongoose";
import { resolve } from "path";
import { fileURLToPath } from "url";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
// Import configs using dynamic imports to avoid build issues
const getValidCircleValues = () => ["community", "founder", "practitioner"];
const memberSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
emailHistory: [
{
email: { type: String, required: true },
changedAt: { type: Date, default: Date.now },
},
],
name: { type: String, required: true },
circle: {
type: String,
enum: getValidCircleValues(),
required: true,
},
contributionAmount: {
type: Number,
required: true,
min: 0,
validate: {
validator: Number.isInteger,
message: 'contributionAmount must be a whole number',
},
},
role: {
type: String,
enum: ["member", "admin"],
default: "member",
},
status: {
type: String,
enum: ["pending_payment", "active", "suspended", "cancelled", "guest"],
default: "pending_payment",
},
helcimCustomerId: String,
helcimSubscriptionId: String,
billingCadence: {
type: String,
enum: ['monthly', 'annual'],
default: 'monthly',
},
paymentMethod: {
type: String,
enum: ["card", "bank", "none"],
default: "none",
},
subscriptionStartDate: Date,
subscriptionEndDate: Date,
nextBillingDate: Date,
lastCancelledAt: Date,
slackInvited: { type: Boolean, default: false },
slackInviteStatus: {
type: String,
enum: ["pending", "sent", "failed", "accepted", "joined"],
default: "pending",
},
slackUserId: String,
// Profile fields
pronouns: String,
timeZone: String,
avatar: String,
studio: String,
bio: String,
location: String,
socialLinks: {
mastodon: String,
linkedin: String,
website: String,
other: String,
},
showInDirectory: { type: Boolean, default: true },
craftTags: [String],
board: {
slackHandle: String,
},
notifications: {
events: { type: Boolean, default: true },
},
inviteEmailSent: { type: Boolean, default: false },
inviteEmailSentAt: Date,
agreement: {
acceptedAt: Date,
},
taxReceiptPreferences: {
filesCanadianTaxes: { type: Boolean, default: false },
middleInitial: { type: String, default: null },
confirmedAddress: {
street: { type: String, default: null },
city: { type: String, default: null },
province: { type: String, default: null },
country: { type: String, default: null },
postalCode: { type: String, default: null },
},
setupCompletedAt: { type: Date, default: null },
},
// Magic link single-use enforcement
magicLinkJti: String,
magicLinkJtiUsed: { type: Boolean, default: false },
// Session revocation via token versioning
tokenVersion: { type: Number, default: 0 },
memberNumber: { type: Number, unique: true, sparse: true },
onboarding: {
completedAt: { type: Date, default: null },
eventPageVisited: { type: Boolean, default: false },
boardPageVisited: { type: Boolean, default: false },
wikiClicked: { type: Boolean, default: false },
skipped: {
profileTags: { type: Boolean, default: false },
visitEvent: { type: Boolean, default: false },
board: { type: Boolean, default: false },
wiki: { type: Boolean, default: false },
},
},
createdAt: { type: Date, default: Date.now },
lastLogin: Date,
});
// Check if model already exists to prevent re-compilation in development
export default mongoose.models.Member || mongoose.model("Member", memberSchema);