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.
This commit is contained in:
parent
0f2f1d1cbf
commit
208638e374
37 changed files with 1980 additions and 340 deletions
116
server/api/internal/reconcile-payments.post.js
Normal file
116
server/api/internal/reconcile-payments.post.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Reconciliation cron route — invoked by `netlify/functions/reconcile-payments.mjs`
|
||||
* on a daily schedule. Mirrors the loop in `scripts/reconcile-helcim-payments.mjs`
|
||||
* but lives inside Nitro so it can use auto-imported utils + the runtime config.
|
||||
*
|
||||
* Auth: shared-secret header `X-Reconcile-Token` matched against
|
||||
* `runtimeConfig.reconcileToken` (env: NUXT_RECONCILE_TOKEN). Machine-to-machine
|
||||
* only — no user session involved.
|
||||
*
|
||||
* Behavior:
|
||||
* - For every Member with a helcimCustomerId, list Helcim transactions and
|
||||
* upsert Payment docs (idempotent via `helcimTransactionId` unique index).
|
||||
* - Transient Helcim API errors are retried up to 3 times with exponential
|
||||
* backoff (250ms / 500ms / 1000ms). On final failure the member is counted
|
||||
* as `memberErrors` and the loop continues.
|
||||
* - Never passes `sendConfirmation: true` — the cron back-fills history and
|
||||
* must not re-send confirmation emails.
|
||||
* - `?apply=false` switches to dry-run: counts what WOULD be created via
|
||||
* Payment.findOne, no writes.
|
||||
*
|
||||
* Returns a JSON summary; logs `[reconcile] done <summary>` to stdout.
|
||||
*/
|
||||
import Member from '../../models/member.js'
|
||||
import Payment from '../../models/payment.js'
|
||||
import { listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
||||
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
||||
|
||||
const RETRY_ATTEMPTS = 3
|
||||
const BASE_DELAY_MS = 250
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function listTransactionsWithRetry(customerCode) {
|
||||
let lastErr
|
||||
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await listHelcimCustomerTransactions(customerCode)
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
if (attempt < RETRY_ATTEMPTS) {
|
||||
await sleep(BASE_DELAY_MS * 2 ** (attempt - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const expected = config.reconcileToken
|
||||
const provided = getHeader(event, 'x-reconcile-token')
|
||||
if (!expected || provided !== expected) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const apply = getQuery(event).apply !== 'false'
|
||||
|
||||
const members = await Member.find(
|
||||
{ helcimCustomerId: { $exists: true, $ne: null } },
|
||||
{ _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimSubscriptionId: 1, billingCadence: 1 }
|
||||
).lean()
|
||||
|
||||
let txExamined = 0
|
||||
let created = 0
|
||||
let existed = 0
|
||||
let skipped = 0
|
||||
let memberErrors = 0
|
||||
|
||||
for (const member of members) {
|
||||
let txs
|
||||
try {
|
||||
txs = await listTransactionsWithRetry(member.helcimCustomerId)
|
||||
} catch (err) {
|
||||
memberErrors++
|
||||
console.error(`[reconcile] member=${member._id}: ${err?.message || err}`)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const tx of txs) {
|
||||
txExamined++
|
||||
if (tx.status === 'other') {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
const existing = await Payment.findOne({ helcimTransactionId: tx.id })
|
||||
if (existing) existed++
|
||||
else created++
|
||||
continue
|
||||
}
|
||||
|
||||
// Note: deliberately NOT passing sendConfirmation — cron back-fills must
|
||||
// not re-send confirmation emails for transactions the member has already
|
||||
// been notified about (or that pre-date Mongo Payment tracking entirely).
|
||||
const result = await upsertPaymentFromHelcim(member, tx)
|
||||
if (result.created) created++
|
||||
else if (result.payment) existed++
|
||||
else skipped++
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
membersScanned: members.length,
|
||||
txExamined,
|
||||
created,
|
||||
existed,
|
||||
skipped,
|
||||
memberErrors,
|
||||
apply
|
||||
}
|
||||
console.log('[reconcile] done', summary)
|
||||
return summary
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue