Production hosting is Dokploy on Hetzner, not Netlify. The Nitro app itself is host-agnostic — Dockerfile + Traefik-aware OIDC + xff-aware rate limiting were already in place — but the Deploy checklist and the daily reconcile cron were Netlify-specific. - LAUNCH_READINESS.md: split Deploy checklist into one-time host setup + cutover; replace "Netlify scheduled function" with a Dokploy Scheduled Task (curl + X-Reconcile-Token); call out the BASE_URL exact-match origin gotcha at customer.post.js:11-15 and the NODE_ENV=production requirement. - Delete netlify.toml and netlify/functions/reconcile-payments.mjs. The Nitro route at server/api/internal/reconcile-payments.post.js stays — it's host-agnostic; only the trigger moves into Dokploy. No code changes. validate-env.js still hard-fails on missing MONGODB_URI / JWT_SECRET / RESEND_API_KEY / HELCIM_API_TOKEN at boot. Tests: 758 passing, 2 skipped, 0 failing.
15 KiB
Launch Readiness
Status as of 2026-04-20. Target launch: before 2026-05-01.
Single source of truth for work remaining before cutover. P0 blocks launch; P1 is strongly preferred but survivable. Completed items are archived — see ~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md. Post-launch backlog lives in docs/TODO.md.
Current state
- Vitest snapshot 2026-04-25 ~18:23 local: 703 passing / 8 failing / 2 skipped (713 total). The previously-flagged 6 helcim-payment failures are now green. The 8 current failures are in
tests/server/api/auth-verify.test.jsandtests/server/api/cancel-subscription.smoke.test.js, both belonging to in-flight Phase 5 fixes (#10 and #9) being landed by parallel impl subagents — they will resolve as those branches merge. - All launch code is on local
main: Helcim plan consolidation, contribution-amount redesign, cadence UX unification, and receipts Phase 1. Not pushed — site is not on Netlify yet. - Helcim plan consolidation migration ran against prod 2026-04-18 (Monthly plan id
50302, Annual plan id50303). - Contribution-amount migration has NOT yet been run against prod.
- Receipts Phase 1 code is shipped; remaining work is deploy-time only (see Deploy checklist).
P0 — Must fix before launch
None outstanding.
P1 — Strongly preferred before launch
None outstanding.
Deploy checklist
Applies when the app is deployed to Dokploy on Hetzner. Build is via the in-repo Dockerfile (node:20-alpine, runs node .output/server/index.mjs on port 3000); Dokploy autodetects it. Traefik (Dokploy's reverse proxy) handles SSL; oidc-provider.ts:194 and the rate-limit middleware already trust X-Forwarded-Proto / X-Forwarded-For.
One-time host setup
- Provision the Dokploy app pointing at this repo. Build context: repo root. Default Dockerfile. Container port:
3000. - Set env vars in the Dokploy UI (full list below). The
validate-env.jsNitro plugin fails fast at boot ifMONGODB_URI/JWT_SECRET/RESEND_API_KEY/HELCIM_API_TOKENare missing — container refuses to start, so misconfig surfaces immediately in logs. BASE_URLmust exactly match the public origin (e.g.https://ghostguild.org, no trailing slash). The/api/helcim/customerorigin check atserver/api/helcim/customer.post.js:11-15does exact-match comparison against theOriginheader — ifBASE_URLis wrong or unset, signup 403s.NODE_ENV=productionmust be set. Without it:Securecookie flag, HSTS, and CSP all silently no-op.- Add a Dokploy Scheduled Task for daily reconciliation. Command:
Schedule:curl -fsS -X POST "$BASE_URL/api/internal/reconcile-payments" -H "X-Reconcile-Token: $NUXT_RECONCILE_TOKEN"0 4 * * *(or any time of day). The Nitro route does the heavy lifting (Mongo iteration, Helcim API, retries) — the scheduler just wakes it up.
Cutover
- Push local
maintoorigin/main. - Run
node scripts/migrate-contribution-amount.cjs --applyagainst prod Mongo BEFORE the new code serves traffic. Idempotent; dry-run on local counted 34 members. RequiresMONGODB_URIin env. The script writescontributionAmount(Number) derived from existingcontributionTier(String) on every Member doc; the old field is left intact for a window. - Set
NUXT_HELCIM_MONTHLY_PLAN_ID=50302in Dokploy env. - Set
NUXT_HELCIM_ANNUAL_PLAN_ID=50303in Dokploy env. - Set
NUXT_RECONCILE_TOKENto any 32+ char random string. Shared secret between the Dokploy scheduled task and/api/internal/reconcile-payments. - Deploy.
- Run
node scripts/reconcile-helcim-payments.mjs --applyagainst prod Mongo AFTER the new code serves traffic to backfill Payment records for pre-existing members. Idempotent (uniquehelcimTransactionId); the daily Dokploy cron picks it up from there. - Prod audit for pre-fix series-pass bypass registrations. Fixed in
f34b062+4e1888a(2026-04-20). Before that, child events of pass-only series (tickets.requiresSeriesTicket=true && tickets.allowIndividualEventTickets=false) accepted drop-in registrations from non-pass-holders. For every such series, list its child-eventregistrationswhere the registrant is not in the parent series' pass-holder list, filter toregisteredAt < 2026-04-20, and decide per-case: grandfather (keep + notify), refund + unregister, or silently unregister. Local Mongo was scrubbed of 2 such rows on 2026-04-20; prod was intentionally untouched. - Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303. We send our own CRA-safe confirmation via Resend (
server/emails/paymentConfirmation.js) triggered fromupsertPaymentFromHelcim; leaving Helcim's default on = duplicate emails. - Run one real test charge against the deployed app and verify (a) a Payment doc in Mongo with
amount,paymentType,status: 'success', and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing). - Rotate HELCIM_API_TOKEN in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in
window.__NUXT__payload until commit208638e. - Trigger the daily reconcile task once manually in Dokploy to confirm scheduled task + token are wired correctly. Expect a
[reconcile] done {...}log line.
Env vars required in Dokploy (reference):
NODE_ENV=productionBASE_URL(exact public origin, no trailing slash)MONGODB_URIJWT_SECRET(orNUXT_JWT_SECRET— theNUXT_variant wins)RESEND_API_KEYHELCIM_API_TOKENNUXT_HELCIM_MONTHLY_PLAN_ID=50302NUXT_HELCIM_ANNUAL_PLAN_ID=50303NUXT_PUBLIC_HELCIM_PORTAL_URLNUXT_RECONCILE_TOKEN(32+ char random string)SLACK_BOT_TOKENOIDC_COOKIE_SECRET
Fixed 2026-04-25
Day-of-launch security and correctness audit. All commit shas TBD until Phase 5.
CRITICAL (security)
- Fix #1 —
HELCIM_API_TOKENremoved from public runtime config + deaduseHelcim.jsdeleted. Token must be rotated post-deploy (was previously exposed viawindow.__NUXT__). - Fix #2 —
/api/helcim/customergated with origin check + per-IP/email rate limit + magic-link email verification (replaces unauthenticatedsetAuthCookie). - Fix #3 —
/api/events/[id]/paymentdeleted (dead code with auth bypass).processHelcimPaymentstub +eventPaymentSchemaremoved. - Fix #4 —
/api/helcim/initialize-paymentre-derives ticket amount server-side viacalculateTicketPrice; newseries_ticketmetadata type. - Fix #5 —
/api/helcim/customerupgrades existingstatus:guestmembers in place rather than rejecting with 409.
HIGH (correctness)
- Fix #6 — Recurring reconciliation: Netlify scheduled function calls
/api/internal/reconcile-paymentsdaily. RequiresNUXT_RECONCILE_TOKENenv var. - Fix #7 —
validateBeforeSave: falseadded to event subdoc saves (waitlist endpoints) to dodge legacy location validators. - Fix #8 — Series-pass purchase always creates a guest Member when caller is unauthenticated, mirroring event-ticket flow.
- Fix #9 —
cancel-subscriptionleaves statusactive(per ratified bylaws); addslastCancelledAtaudit field. - Fix #10 —
/api/auth/verifyusesvalidateBodywith.strict()Zod schema. - Fix #11 — Added 8 vitest cases for
cancel-subscription.post.js(was uncovered).
Side-quests
- Visual audit Phase 4 changes (events/series surface)
- Per-fix branch verification: see
docs/superpowers/specs/2026-04-25-fix-*.md
Manual browser tests still needed
None outstanding. All launch-blocking flows verified via local dev or cloudflared tunnel with real Helcim test card + real email (see archive for the full log). The one remaining browser verification is the staging test charge bundled into the Deploy checklist above.
Bylaws decoupling — follow-ups (added 2026-04-18)
Context: bylaws are being amended to remove automatic termination for nonpayment. Membership status will be fully decoupled from payment status; failed payments trigger committee outreach, not status change. Copy + UI access gates already aligned in useMemberStatus.js and account.vue (2026-04-18). Server-side status gating shipped as B2 (see archive). The behavioral changes below remain.
Not blocking launch — the amendment hasn't passed yet, and the user-visible copy/UI is already consistent. Pick up once the amendment is ratified.
B1. cancel-subscription flips status to pending_payment
server/api/members/cancel-subscription.post.js:31,48- When a member cancels their paid subscription, status is set to
pending_paymentand contribution amount to0. Under the new model, cancelling a payment plan moves the member to the $0 contribution — status should stayactive. - Fix: change
status: 'pending_payment'→status: 'active'in both thefindByIdAndUpdatepayload (line 31) and the response (line 48). Comment at line 26 also needs updating ("(not cancelled) so member can re-subscribe" → reflect new framing). - Add coverage in
tests/server/api/cancel-subscription.test.jsif it doesn't already exist.
B3. Vestigial pending_payment status
- Once payment is fully decoupled,
pending_paymentno longer gates anything and is functionally equivalent toactive. Consider removing it from the enum (server/models/member.js:38,server/utils/schemas.js:299) and treating new signups asactivefrom the moment of account creation. - Touches: signup flow (
helcim/customer.post.js:34,invite/accept.post.js:48), admin filter UI (app/pages/admin/members/index.vue:45,382,499,1145,[id].vue:69,286), admin alerts (server/utils/adminAlerts.js:22,100-116,server/models/adminAlertDismissal.js:6), and a data migration to flip existingpending_paymentrows toactive. - Larger refactor — break out into its own ticket once B1 lands.
B4. Admin "Pending Payment" filter label (cosmetic)
app/pages/admin/members/index.vue:45,499,[id].vue:69showpending_paymentas "Pending Payment". If B3 removes the status entirely, this disappears too. If we keeppending_paymentfor now, rename in admin UI to "Payment setup incomplete" so admins also stop conflating it with membership state.
Post-launch backlog
See docs/TODO.md for:
- Button minimum target size (WCAG AAA 2.5.5).
/oidc/interaction/[uid]routing quirk.- Admin layout migration from
guild-*tokens to zine spec. - Admin dashboard quick-action button contrast.
- Members table NAME column clipping.
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption).
tickets/available.get.js:115memberSavingsblock reports$0 savedfor inactive members — cosmetic; suppress comparison block when!hasMemberAccess(member)if it ever surfaces in UI.- Simplify-pass follow-ups (2026-04-25): source-grep test bloat, login/verify rate-limit gap, stringly-typed
metadata.type, reconcile-payments sequential loop, stalenew Date()in events list,loadPublicSerieshelper extraction.
Known gotchas worth addressing post-launch
- Subscription cache fed wrong field on CREATE.
subscription.post.jsandupdate-contribution.post.jsreadsubscription.nextBillingDatefrom Helcim's CREATE response, but Helcim returnsdateBilling. The lazy refresh insubscription.get.jsmasks this (handles both shapes), so next-charge rendering works — but the cache starts empty. Fix at the CREATE sites so the cache is correct from first write. - Admin edit does not sync Helcim
recurringAmount./admin/members/[id]PUT writescontributionAmountdirect to Mongo by design. Admins must PATCH Helcim manually. Worth surfacing in admin UI or docs. - Cadence switch rejected on active subscriptions.
update-contribution.post.js:184-189refuses cadence changes mid-subscription; no UI toggle exists on/member/account. Adding cadence switch would require a Helcim subscription replacement flow, not a plain update. SeriesPassPurchase.vuedoesn't auto-refresh after purchase. (Observed 2026-04-21 during Phase 4 series-pass functional tests.) Component's local$fetchto/api/series/{id}/tickets/availablefires on mount +userEmailwatch, but isn't re-invoked after a successful purchase — the "already registered" state only appears on next navigation. Parent page callsrefreshNuxtData()but the component doesn't participate in it. Fix: callfetchPassInfo()after the success toast inhandleSubmit, or lift the fetch touseAsyncDataso it can be refreshed from outside.- S2 test fixture
id/sluginconsistency. (Local dev only.) Seeded S2 series hasid: 'test-s2-drop-in-allowed'butslug: 'test-s2-drop-in-allowed-series'. Doesn't affect prod — fix the seed script if anyone re-runs fixtures and is confused whyid-based Mongo queries return empty.
Events-surface visual audit — deferred items (2026-04-21)
Context: Phase 4 audit against docs/specs/events-visual-audit-findings.md fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across EventTicketCard, EventTicketPurchase, EventSeriesTicketCard, SeriesPassPurchase. Items below were explicitly deferred or out of reach.
- Success-state color convention (4 instances). "You're Registered!" blocks use
--candle(gold) instead of--green. TouchesEventSeriesTicketCard.vue:186-196(still uses phantomcandlelight-*classes — preserved byte-for-byte pending decision) and registered-state wrappers inSeriesPassPurchase.vue. Needs a UX call on whether success should render gold (zine-consistent) or green (semantic). Once decided, finish the phantom-palette removal on those 4 lines. - Sidebar breakpoint unverified.
app/layouts/default.vue:89hides the sidebar at ≤1024px per spec. Browserresize_windowtool refused viewport changes during the audit, so the actual crossover and any layout shift at 1023–1025px was never visually confirmed. Do a manual responsive check before declaring the sidebar pattern shipped. EventTicketPurchase.vue:469magic padding..consent-hint { padding-left: 24px; }is a hardcoded offset to align the hint under the checkbox text. Cosmetic; swap for a gap/grid approach when touching the consent block next.- Toast API rename unverified. Nuxt UI v4 may have renamed
toast.add({ timeout })→{ duration }. CurrentSeriesPassPurchase.vuetoasts still passtimeout. No visible breakage, but worth confirming against current Nuxt UI docs. .section-labelextraction candidate. Several audited files repeat the same uppercase/letter-spaced small label pattern inline. Low-priority refactor into a utility class inmain.css.- Past-events toggle component. Existing, untouched this pass; noted in findings doc as a future consistency check.
Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
- Rename admin members column header "Tier" → "Contribution" (
app/pages/admin/members/index.vue:265). - Delete dead
app/components/TierPicker.vue. - Update stale tier comment in
app/composables/useMemberPayment.js:59. - Update error log message referencing "tier" in
server/api/members/update-contribution.post.js:221. - Rename
handleUpdateTierhandler inapp/pages/member/account.vue.