Promote inline STATUS_LABELS copies (admin/members/index.vue,
member/account.vue) into app/config/memberStatus.js, matching the
app/config/circles.js pattern. Drive admin/members/[id].vue status
select from the same constant — completes the alignment started in
441a5f5.
Use the softer member-facing copy as canonical: "Paused" / "Closed"
instead of "Suspended" / "Cancelled".
Also fix markSlackInvited's non-reactive Object.assign on a plain
object inside a useFetch array — replace with index-find + element
reassignment so the row UI refreshes without a manual reload.
The alt-text input was hard-coding border/bg via inline style="..." after
the phantom-Tailwind sweep, which can't carry pseudo-class rules.
Per CLAUDE.md, inputs focus to --candle. Moved to a scoped style block
with a real :focus rule.
The status options were duplicated three times in admin/members/index.vue
(filter dropdown, edit-modal dropdown, statusLabel helper). The recent
"Pending Payment" → "Payment setup incomplete" rename only landed in
two of the three sites. Both <select>s now v-for over the existing
STATUS_LABELS const, so any future label change happens in one place.
Side effect: the edit-modal dropdown order is now
(active, pending_payment, suspended, cancelled) to match the filter
dropdown — was previously pending_payment-first.
- /join "How membership works" lists community (not Slack) as a benefit;
adds a note that Slack invitations come in monthly onboarding waves.
- Dashboard slack-coming note drops "2–3 weeks" timeline; uses the same
monthly-waves phrasing.
- Welcome email no longer points new members to Slack (which they don't
yet have access to); directs them to reply instead.
- B: token-equivalent rgba → color-mix(srgb, var(--ember|green|candle) X%, transparent) so colors track dark mode
- C: drop stale var(--green, #...) fallbacks (canonical token now defined in main.css)
- F: inline circle badge → <CircleBadge/> in admin/index, members/[id], members/index
Test file: drop step markers, regression explainers, and the lead
comment block that restated the contract; hoist the shared subscription
request body to a const; move Member mock defaults into the test that
uses them. Two it() cases unchanged.
Events page: drop WCAG comment that narrated what the
.past-toggle:focus-visible selector already says.
Custom .past-toggle button had no focus indicator — keyboard users
got nothing. Match the canonical WCAG 2.4.7 outline used on .btn
and .zine-select (dashed candle, 3px offset).
Error state and main registration card swap bg-ember-*/border-ember-* and
bg-guild-*/border-guild-* utilities for design tokens in a scoped style
block. Error state uses the codebase's --ember + 8% color-mix pattern;
registration card uses --surface + dashed --border per the zine spec.
Swap bg-guild-*/border-guild-*/text-guild-* utility classes for design tokens
in a scoped style block. Drops rounded-* per the no-rounded-corners rule and
uses dashed borders for the structural block per the zine spec.
Sibling sweep to dc2becf: NaturalDateInput.vue and ImageUpload.vue used
candlelight-/ember-/guild-* utility classes that aren't defined in the
project's Tailwind palette and rendered as no-ops. Swapped to inline
styles using --candle, --ember, --text-dim/faint/bright, --border,
--input-bg, --surface. Drag-state and parsed-date notices follow the
color-mix(... 15%) + 1px solid pattern from dc2becf.
Renders only when status==='active' && !slackInvited. Hidden for
pending_payment, suspended, cancelled, guest, and any member already
flagged as invited. Lives inside the existing ClientOnly tree at the
top of the dashboard so it never SSRs.
Plain inline text in the welcome region — no banner, no callout. The
2–3 week window is admin-side workflow; the copy avoids cohort/wave
language.
Replaces the placeholder Slack-invite handler with a call to the new
PATCH /api/admin/members/:id/slack-status endpoint. Status labels are
reworded to match reality (no Slack API call from this app):
- Pending → Not yet invited
- Invited → Invited <slackInvitedAt>
- Action button copy → 'Mark as Slack invited'
- Removes slackInviteStatus reads from the member detail page (the
remaining repo-wide sweep lands in the cleanup task).
TierPicker.vue is a 5-tier preset picker from before the arbitrary-
amount contribution redesign. Zero imports across app/ and server/ —
purely dead code (99 lines).
Strike two LAUNCH_READINESS bullets that describe already-fixed
issues: the "stale tier comment" in useMemberPayment.js (no `tier`
references remain in that file), and the SeriesPassPurchase auto-
refresh gotcha (fetchPassInfo() already runs after the success path
at line 318).
Nuxt UI 4's Toast component reads `duration` (default 5000ms), not
`timeout` — the property was silently ignored. Behavior unchanged
since 5000ms matched the default. Fix the call site to be honest.
Strike the now-resolved gotcha from LAUNCH_READINESS.md.
a11y (main.css):
- Nuxt UI's default placeholder color (text-dimmed = #a6a09b) failed WCAG
AA contrast on cream (2.43:1) and white (2.58:1) backgrounds, blocking
axe checks on /member/profile (timezone) and /admin/events/create
(tags). Override [data-slot="placeholder"] globally to var(--text-dim)
(#5a5040), comfortably above 4.5:1 on both surfaces.
auth.spec.js (logout):
- Same hydration race as the board/admin-board-channels click tests:
/admin's sidebar Sign-out @click handler isn't bound when Playwright
fires the click immediately after admin-tag visibility, so the click
no-ops and waitForResponse for /api/auth/logout times out.
- Add waitForLoadState('networkidle') after goto so hydration completes
before the click.
Helcim refuses paymentType:'verify' for cards already saved on a
customer ("A new card must be entered for saving the payment method"),
breaking every "Complete Payment" retry after a partial-failed signup.
Add GET /api/helcim/existing-card and short-circuit HelcimPay verify in
useMemberPayment + payment-setup.vue when a saved card is found, going
straight to subscription creation. The two existence-check fetches run
in parallel with get-or-create-customer so no extra round-trip latency
in the common path.
Follow-up to 51230e5. /simplify review surfaced residual duplication
and a timer leak.
- useHelcimPay: extract _initializeTicket(metadata, errorPrefix) to
collapse initializeTicketPayment + initializeSeriesTicketPayment
(95% identical bodies). Drop the dead `amount` arg from initialize-
TicketPayment — server re-derives ticket amounts in initialize-
payment.post.js and never reads body.amount for ticket types.
Capture timer ids and clearTimeout on resolve/reject so the 10-min
payment timer and 5-second observer timer stop leaking after every
payment.
- EventTicketPurchase: caller updated for the dropped arg.
- verify.post.js: replace inline jwt.sign + setCookie block with the
setAuthCookie(event, member) helper. verify was the last hand-rolled
caller after the helper was extracted in 208638e.
- LAUNCH_READINESS: add simplify-pass-followups bullet pointing to the
six deferred items in docs/TODO.md.
Tests: 758 passing, 2 skipped, 0 failing.
Follow-up to 208638e. Code review surfaced a few real issues; this
commit addresses them.
- login.post.js now uses the new sendMagicLink util instead of
duplicating the jti/jwt/Resend/logActivity logic. Reduces 60 lines.
- sendMagicLink accepts an optional pre-loaded Member doc, skipping
the redundant findOne when the caller already has one. customer.post.js
passes the just-created/upgraded member, dropping signup from 3
Mongo round-trips to 1 (lookup is gone; jti burn remains).
- sendMagicLink now lowercases the email defensively so callers don't
have to remember.
- rateLimit.js: replaced an effectively-dead eviction line with a
probabilistic sweep (~1% of calls scan and evict keys whose newest
entry has aged out). Caps unbounded Map growth under random-key
spraying.
- reconcile-payments.post.js: 401/403/404 from Helcim now bails out
immediately instead of burning all 3 retry attempts; dry-run
summary filters via the same RECONCILABLE_STATUSES set as apply
mode so counts match.
- Deleted WHAT-comments and section banners per CLAUDE.md no-comment
rule. Kept genuine WHY-comments (validateBeforeSave rationale,
amount-IGNORED-for-tickets, sendConfirmation deliberately-omitted).
Tests: 758/760 passing (unchanged).
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.
Self-cancel endpoint now rejects paid registrations (public, series_pass,
or paid member tickets) with a 403 pointing to /policies/refunds. Free
and $0-member registrations still self-cancel as before. Adds the
refunds policy page referenced in the error message.
Friendlier tone + ghost emoji on invite/welcome subjects; invite
templates now offer a reply-to-this-email fallback; tighten OIDC
wiki sign-in and event registration confirmation copy.
Adds a small paragraph directly below the tier list stating the
Baby Ghosts Studio Development Fund charity status, noting that
Canadian taxpayers can claim contributions, and that setup for
receipts happens after joining. Styled in parallel to
.solidarity-note (12px, --text-dim, 1.65 line-height) so it reads as
a bullet, not a banner.
Scope is /join only — /accept-invite and /member/account copy is
untouched per spec §3.
Three related changes on /member/account:
1. Payment History section now renders when contributionAmount > 0 OR
past payments exist. Previously a paid member who switched to $0 lost
visibility of their own past charges.
2. New "Next charge: $X on DATE" row renders above the transaction list
when nextPaymentDate is available, using --candle dashed border.
3. server/api/helcim/subscription.get.js now reads dateBilling from
Helcim's GET response and handles data as either object or array.
Helcim's real shape is {data: {id, dateBilling, ...}} — the old code
expected {data: [{nextBillingDate}]} and returned empty strings, so
the Membership-card "Next payment" row never rendered for members
whose cached date was missing. subscription.post.js and
update-contribution.post.js have the same wrong field name in their
CREATE flows; left for a follow-up — the GET refresh masks it.
Manual edit-flow and admin-flow tests also recorded in
docs/LAUNCH_READINESS.md.
Extract shared SignupFlowOverlay component. Static "Monthly Contribution"
label on all three contribution inputs (was misleadingly dynamic).
"Per Year"/"Per Month" toggle copy; Per Year default on accept-invite,
Per Month default on join. Live billing-summary card on both signup
flows. Welcome-heading on dashboard via ?welcome=1 for new signups.
$0-member polish on account page (hide payment-history + Solidarity
Fund prompts). State-aware contribution-change hint. Invite accept now
creates Helcim customer and sets auth cookie server-side for both free
and paid branches. Pre-registrant invite + /join signup flows manually
verified against Cleo Nguyen preReg and $0-$50 variants.
Persist nextBillingDate on subscription create/update; unset on
cancel or downgrade to free. Account page displays the cached
date and lazily refreshes from Helcim when the cached value is
within 24h of now (or missing).
The .circle-desc text used --text-faint, which failed WCAG AA on the
selected/hover tile surfaces (4.01:1 light / 4.31:1 dark). Promote to
--text-dim to clear 4.5:1 against all tile states.
Add Payment history section (live-read from Helcim, with loading/empty/error states)
and Change card flow (HelcimPay.js zero-dollar auth -> POST /api/helcim/update-card)
to /member/account. Relabel Helcim portal link to "Advanced billing in Helcim →"
and demote it to a secondary link at the bottom of the billing group.
Removes the 3-second setTimeout that deferred navigateTo('/welcome').
The overlay success state was a holdover from the pre-refactor Step-3
inline block; now that /welcome is the single welcome surface, the
delay just stalls a completed action and fights the continuous-flow
goal of the overlay.
After createSubscription() calls checkMemberStatus(), isAuthenticated
flips to true and the <template v-else> branch unmounts, taking the
Teleport (and its overlay) with it. The authenticated 'You're already a
member' UI then showed for the 3-second pre-redirect delay, producing a
visible flash before navigateTo('/welcome') fired.
Teleport now lives at the root div alongside the v-if/v-else branches,
so the overlay stays mounted through the auth state transition and
covers the page continuously until the redirect.