ghostguild-org/docs/LAUNCH_READINESS.md

13 KiB
Raw Blame History

Launch Readiness

Status as of 2026-04-17 (audited). Target launch: before 2026-05-01.

Single source of truth for the work that must happen before cutover to production. Anything marked P0 blocks launch. P1 is strongly preferred but survivable. Post-launch backlog lives in docs/TODO.md.


Current state

  • Vitest: 577/577 passing.
  • Working tree clean on main.
  • Infra in place: /api/health, CSRF + security headers + rate-limit middleware, .env.example, Dockerfile, .dockerignore, OWASP ASVS L1 phases 03.

P0 — Must fix before launch

1. Write Privacy Policy and Terms of Service

  • /policies/privacy and /policies/terms currently render a "being finalized" placeholder via app/pages/policies/[slug].vue.
  • Community Guidelines links to both as part of the agreement members accept at signup, so they need real text before we can claim the agreement is complete.
  • Fix: author both; policies live on THIS site (not Obsidian — unlike Code of Conduct and Conflict Resolution). Implementation path once copy exists: extend the POLICIES map in [slug].vue with a body field, or split into dedicated privacy.vue / terms.vue pages. Remove the "being finalized" placeholder either way. /policies/by-laws can stay as a placeholder until launch; by-laws are referenced but not user-visible at signup.

2. get-or-create-customer creates duplicate Helcim customers 2026-04-17

  • server/api/helcim/get-or-create-customer.post.js
  • Fixed: short-circuits on member.helcimCustomerId via getHelcimCustomer(), only falling through on 404 (other errors rethrow — silent recreate was the root cause). Email-search fallback now case-insensitive. Both member.save() calls converted to findByIdAndUpdate(..., { runValidators: false }).
  • Requires a manual browser retest of the tier-upgrade flow before launch (already in the Manual browser tests list).

3. Run pre-deploy migration scripts against production 2026-04-17

  • Imported 97 Baby Ghosts pre-registrants into preregistrations (2 more than the original 95 — new signups between doc authoring and run).
  • Stripped stale privacy subdocs from members via mongo shell (script skipped; target data was already empty on real users).
  • Dropped legacy communityEcology / onboarding.ecologyPageVisited fields and renamed activitylogs.action: community_ecology_updatedboard_updated via mongo shell (rename script skipped to avoid $rename target-collision edge case).
  • test@example.com removed from preregistrations post-import.

P1 — Strongly preferred before launch

4. helcim/subscription.post.js Slack helper still uses member.save() 2026-04-17

  • server/api/helcim/subscription.post.js
  • Fixed: all three member.save() calls in the inviteToSlack helper converted to Member.findByIdAndUpdate(member._id, { $set: { ... } }, { runValidators: false }), scoped to the Slack fields each branch actually mutates. Matches the pattern applied to login.post.js, create.post.js, verify.post.js, logout.post.js, cancel-subscription.post.js, update-contribution.post.js, and get-or-create-customer.post.js.

5. Series pass pricing mismatch 2026-04-17

  • server/api/series/[id]/tickets/purchase.post.js:38-45 tries requireAuth then falls back to email lookup; line 67 rejects submitted ticketType that doesn't match the entitlement. Gap: if the client omits ticketType, no mismatch is raised, so the backend can still charge a paid cart while recording a member-tier registration.
  • Fixed: seriesTicketPurchaseSchema in server/utils/schemas.js now requires ticketType as z.enum(['member', 'public', 'guest']) (no .optional()), so validateBody 400s any purchase that omits it — the existing mismatch check at purchase.post.js:67 now runs on every request. SeriesPassPurchase.vue updated to send ticketType: passInfo.ticket.type (server still re-derives entitlement from the authenticated member / email lookup; the submitted value is only used for the consistency check). Schema coverage added in tests/server/api/validation.test.js.

6. Set NUXT_PUBLIC_HELCIM_PORTAL_URL in production 2026-04-18

  • Wired in nuxt.config.ts:102. Without it, account.vue doesn't render the "Manage billing in Helcim" link for members with helcimCustomerId.
  • Done: set in production env 2026-04-18.

Pre-deploy runbook

Run in this order before cutover. All scripts require MONGODB_URI (production) in env:

# 1. Import pre-registrants from Baby Ghosts
BG_MONGODB_URI="<bg-uri>" MONGODB_URI="<prod-uri>" \
  node server/migrations/import-babyghosts-preregistrations.js
# Verify: 95 records in `preregistrations` with status:'pending'

# 2. Remove stale privacy subdocs (per-field privacy was deleted 2026-04-15)
MONGODB_URI="<prod-uri>" node scripts/unset-member-privacy.js

# 3. Rename ecology collections → board
MONGODB_URI="<prod-uri>" node scripts/migrate-ecology-to-board.cjs

# 4. Consolidate Helcim plans (delete per-tier plans, create Monthly + Annual plans, backfill Mongo)
HELCIM_API_TOKEN="<prod-token>" MONGODB_URI="<prod-uri>" \
  node scripts/helcim-plan-consolidation.js              # dry-run first, inspect output
HELCIM_API_TOKEN="<prod-token>" MONGODB_URI="<prod-uri>" \
  node scripts/helcim-plan-consolidation.js --confirm    # destructive; copy the printed plan ids into env

Env vars required in production:

  • MONGODB_URI
  • JWT_SECRET (or NUXT_JWT_SECRET — the NUXT_ variant wins)
  • RESEND_API_KEY
  • HELCIM_API_TOKEN
  • NUXT_HELCIM_MONTHLY_PLAN_ID (set after running scripts/helcim-plan-consolidation.js --confirm)
  • NUXT_HELCIM_ANNUAL_PLAN_ID (set after running scripts/helcim-plan-consolidation.js --confirm)
  • SLACK_BOT_TOKEN
  • BASE_URL
  • OIDC_COOKIE_SECRET
  • NUXT_PUBLIC_HELCIM_PORTAL_URL (P1 above)

Manual browser tests still needed

These cannot be verified by the Vitest suite — all require a real browser + real Helcim test card + real email.

  • Paid-tier join flow end-to-end (blocked on localhost by HelcimPay.js iframe X-Frame-Options: sameorigin; use cloudflared tunnel or ngrok HTTPS). 2026-04-18 — verified via tunnel; two bugs found and fixed along the way (missing tv in JWT payload, missing welcome email).
  • Event ticket purchase with payment (same iframe limitation).
  • Tier upgrade free → paid. Account → tier change → /member/payment-setup?tier=&circle= → Helcim $0 verify → subscription created. 2026-04-18 — verified via tunnel; one bug found and fixed (ColumnsLayout cols="1" silently dropped default slot on payment-setup.vue).
  • Paid → free downgrade. Confirms Helcim subscription is cancelled.
  • Paid → paid tier swap. Confirms existing subscription is updated, not recreated.
  • Annual join end-to-end. New paid signup picks annual cadence at $15 tier, completes payment, Mongo record has billingCadence: 'annual' and Helcim sub is under Annual Membership with recurringAmount: 150.
  • Annual tier swap within-cadence. Annual $15 member changes to $50 annual, updateHelcimSubscription called with { recurringAmount: 500 }, Mongo contributionTier updated, billingCadence unchanged.
  • Pre-registrant invite → accept flow with paid tier (exercises Helcim customer creation during acceptance).
  • Magic-link login including 15-min expiry and jti burn on reuse.
  • Guest event signup — four branches: new email + consent, new email without consent, existing guest, existing active member. Confirms cookie only sets for new/guest, and confirmation email appends /login link for real members.
  • Mobile responsive layout — sidebar hides ≤1024px, nav works on phone.
  • --text-dim / --text-faint WCAG AA contrast check.

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). 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_payment and tier to '0'. Under the new model, cancelling a payment plan moves the member to the $0 tier — status should stay active.
  • Fix: change status: 'pending_payment'status: 'active' in both the findByIdAndUpdate payload (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.js if it doesn't already exist.

B2. Server doesn't gate member-only events by member.status 2026-04-18

  • Fixed: introduced hasMemberAccess(member) helper in server/utils/tickets.js — returns true only for status ∈ {active, pending_payment}. Used at every server-side enforcement surface:
    • server/utils/tickets.js: calculateTicketPrice, calculateSeriesTicketPrice, and validateTicketPurchase (membersOnly gate) all route the member through hasMemberAccess before granting member-tier pricing or access.
    • server/api/events/[id]/register.post.js: replaces the bare if (!member) gate with hasMemberAccess(member) for both the membersOnly check and the payment-required check; isMember / membershipLevel follow suit.
    • server/api/events/[id]/tickets/purchase.post.js: replaces local isRealMember (which only excluded guests) with shared hasMemberAccess.
    • server/api/series/[id]/tickets/purchase.post.js: derives accessMember and uses it for validation, registration isMember, and membershipLevel.
  • Test coverage: 12 new cases in tests/server/utils/tickets.test.js covering pricing and membersOnly gating across all four inactive statuses (guest, suspended, cancelled, plus pending_payment as the access-conferring counterpart). tests/server/api/event-registration.test.js updated to assert the shared helper. baseMember() test factory now defaults to status: 'active' so the existing happy-path coverage remains valid.
  • Net behavior: pending_payment keeps member access (payment is decoupled per bylaws). guest, suspended, cancelled are uniformly rejected from member-only events at the API and pay public pricing on open events. tickets/available.get.js did not need direct changes — it calls calculateTicketPrice(event, member) which now applies hasMemberAccess internally, so a suspended/cancelled lookup automatically returns public pricing. The cosmetic memberSavings block at available.get.js:115 will report $0 saved for inactive members — harmless, but worth a follow-up to suppress the comparison block when !hasMemberAccess(member) if it ever surfaces in UI.

B3. Vestigial pending_payment status

  • Once payment is fully decoupled, pending_payment no longer gates anything and is functionally equivalent to active. Consider removing it from the enum (server/models/member.js:38, server/utils/schemas.js:299) and treating new signups as active from 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 existing pending_payment rows to active.
  • Larger refactor — break out into its own ticket once B1/B2 land.

B4. Admin "Pending Payment" filter label (cosmetic)

  • app/pages/admin/members/index.vue:45,499, [id].vue:69 show pending_payment as "Pending Payment". If B3 removes the status entirely, this disappears too. If we keep pending_payment for now, rename in admin UI to "Payment setup incomplete" so admins also stop conflating it with membership state.

Stale TODOs in source (non-blocking)

  • server/api/events/[id]/tickets/purchase.post.js asymmetry vs. series endpoint confirmed safe 2026-04-17. No P1-equivalent pricing bypass: ticketPurchaseSchema accepts no ticketType, so the server is authoritative — calculateTicketPrice(event, member) derives the tier from the Member doc looked up by submitted email, with isRealMember excluding status:"guest". Submitting another member's email registers the ticket under their email (not the attacker's), sends the confirmation to them, and the auto-login guard at lines 141-148 explicitly refuses to issue a cookie for a real member, so the attacker gains no ticket, no cookie, and no email. Residual griefing concern (forcing an unsolicited registration onto another member's email) is not a pricing bypass and is out of scope.

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).