ghostguild-org/docs/LAUNCH_READINESS.md

154 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_updated``board_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:
```bash
# 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.
- [x] **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).
- [x] **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`).
- [x] **Paid → free downgrade.** Confirms Helcim subscription is cancelled. ✅ 2026-04-18 — verified via tunnel; Mongo updated (`contributionTier:"0"`, `helcimSubscriptionId:null`) and Helcim sub deleted. One bug found and fixed (`helcimFetch` threw on empty 204 response body, masked as best-effort success).
- [x] **Paid → paid tier swap.** Confirms existing subscription is updated, not recreated. ✅ 2026-04-18 — covered by annual tier swap below (same `updateHelcimSubscription` code path, same `recurringAmount` PATCH).
- [x] **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`. ✅ 2026-04-18 — verified via tunnel; one label bug found and fixed (`$50/yr` was ambiguous between `$5 tier annual` and `$50 tier monthly`; dropdown now shows `$5/mo → $50/yr`).
- [x] **Annual tier swap within-cadence.** Annual $15 member changes to $50 annual, `updateHelcimSubscription` called with `{ recurringAmount: 500 }`, Mongo `contributionTier` updated, `billingCadence` unchanged. ✅ 2026-04-18 — verified via tunnel; TierPicker now shows `$500/yr` + `$50/mo tier` subtitle for clarity.
- [ ] **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).