diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md new file mode 100644 index 0000000..f775290 --- /dev/null +++ b/docs/LAUNCH_READINESS.md @@ -0,0 +1,154 @@ +# 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 0–3. + +--- + +## 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="" MONGODB_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="" node scripts/unset-member-privacy.js + +# 3. Rename ecology collections → board +MONGODB_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="" MONGODB_URI="" \ + node scripts/helcim-plan-consolidation.js # dry-run first, inspect output +HELCIM_API_TOKEN="" MONGODB_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`). +- [ ] **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).