Previously the publicTicket comparison block ran whenever a Member record
existed, which surfaced "$0 saved" for cancelled/suspended/guest accounts.
Use the canonical hasMemberAccess helper so only active/pending_payment
members see the savings comparison.
Wave-based onboarding makes the auto-invite + polling path obsolete.
- Removes SlackService.inviteUserToSlack — admins now send invites
through Slack's UI and flip the flag in our admin endpoint.
- Removes the slack_invite_failed admin alert + its detector. The
alert no longer has a meaningful trigger (we don't attempt invites).
- Archives server/utils/checkSlackJoins.js (and its test) under
_archive/ in case the polling pattern is needed again post-pilot.
- Deletes the Nitro plugin that scheduled checkSlackJoins on boot
+ hourly. Nothing in nitro.config / nuxt.config / package.json
registered it elsewhere.
- Drops the slack_invite_failed branch from adminAlerts.test; the
enum slug stays in adminAlertDismissal so historical dismissal
rows continue to validate.
notifyNewMember (vetting-channel notification) and findUserByEmail
(used by the auto-flag helper) are retained.
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).
Endpoint that flips a member's slackInvited flag manually after the
admin has actually sent the Slack invitation through Slack's UI. No
Slack API call is made from this app.
- Body validated via Zod literal-true schema (no undo path for the
pilot — admins correct mistakes in the database if needed).
- Idempotent: re-marking an already-invited member is a no-op,
preserving the original slackInvitedAt and not duplicating the
activity log entry.
- Activity log: slack_invited_manually, actor = admin from
requireAdmin, subject = the target member.
Replaces the per-file inviteToSlack helpers with a single auto-flag
call. Self-serve activation paths now check for pre-existing workspace
membership (silent on miss) instead of attempting an admin-only invite.
- helcim/subscription.post.js: removed local inviteToSlack; both
free- and paid-tier activation branches now call the helper, then
notifyNewMember with the canonical 'manual_invitation_required' arg.
- members/create.post.js: same shape — helper + canonical notify arg.
- invite/accept.post.js (free-tier branch): added the helper call after
member creation. Free-tier had no prior Slack call (audit confirmed);
paid-tier remains untouched and activates via the Helcim webhook.
Admin-created and CSV-imported members intentionally do NOT call the
helper — admins flip the flag manually after sending the invite.
Test stub for autoFlagPreExistingSlackAccess added to server setup.
Best-effort lookup of an activating member's email in the Slack
workspace. On a hit, flips slackInvited:true and stamps slackInvitedAt
without sending a fresh invite. Races against a 3s timeout and swallows
all errors so activation never blocks on Slack.
- Promotes SlackService.findUserByEmail from private to public so the
helper can call it without a wrapper.
- New activity-log action: slack_access_auto_detected (actor = subject).
- Idempotent: short-circuits when slackInvited is already true.
Callers wired in next commit.
Schema change for wave-based Slack onboarding. The string enum
slackInviteStatus is replaced with a simple slackInvitedAt: Date —
boolean slackInvited is the source of truth, the date records when.
Call sites that flip slackInvited:true must stamp slackInvitedAt
in the same update (no pre-save hook, per findByIdAndUpdate convention).
Sweeps of remaining slackInviteStatus references land in later tasks.
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.
Helcim returns next-charge as `dateBilling` on POST /subscriptions, but the
two CREATE sites were reading `subscription.nextBillingDate`, leaving
`member.nextBillingDate` empty after every signup and free→paid upgrade.
The lazy refresh in subscription.get.js (which already accepts both shapes)
masked it on next account-page load, so renders eventually populated — but
the success response we returned to the client also had `nextBillingDate:
undefined`. Mirror the GET-side resolution at both CREATE sites: prefer
`dateBilling`, fall back to `nextBillingDate`. Existing Number.isNaN guard
unchanged; defensively rejects malformed strings from either field.
Main's middleware-level auth limiter (5 req / 5 min, IP-only) duplicated
the handler-level limiter introduced earlier on this branch (5/hr IP +
3/hr per-email, blocks email enumeration across IPs). Drop the
middleware version and let the handlers own it.
Added ALLOW_DEV_TEST_ENDPOINTS bypass to the rateLimit utility so
parallel E2E runs from 127.0.0.1 don't exhaust per-IP/email budgets,
mirroring the existing middleware bypass.
Trimmed the obsolete middleware auth test; handler-level coverage lives
in tests/server/api/auth-{login,verify}.test.js. Switched IP-isolation
test to the payment path so it still exercises the limiter.
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.
join-flow:
- Form now requires Community Guidelines agreement; tests check the
checkbox before expecting submit to enable.
- Contribution input is a numeric field with preset chip buttons, not a
USelect with $0/mo options — fill the input directly.
- Success state lives in SignupFlowOverlay ("Welcome to Ghost Guild!");
no .success-box exists. Match by heading instead.
- Inline .error-box renders OUTSIDE <form>, so duplicate-email assertion
uses .signup-flow-overlay .error-box (which is the user-facing error).
member-profile:
- "How you appear to other members" copy was retired; replace with the
stable "Show in Member Directory" structural label.
- Add waitForLoadState('networkidle') after goto for ClientOnly auth
hydration so "Edit Profile" reliably appears within timeout.
board:
- Add waitForLoadState('networkidle') after goto so the action-bar's
"+ New Post" click handler is bound before the test clicks.
- Submit button is named exactly "Post" — disambiguate from "+ New Post"
buttons with { exact: true }.
- Delete is a two-step in-card confirm (Delete → Confirm), not a native
browser dialog; drop the page.once('dialog') listener.
admin-board-channels:
- Channel name placeholder is "e.g., coop-formation" (no leading #).
- Slack Channel ID input only appears in the Edit modal (v-if="editingId"),
not on Create — Slack channel is auto-created server-side. Drop the
slack ID fill from the Create step.
- Add waitForLoadState('networkidle') before opening the modal.
Parallel Playwright runs (6 workers, all from 127.0.0.1) burned through the
100 req/min generalLimiter budget within the first ~30s, causing every API
call (including /api/dev/test-login and /api/dev/member-login) to return 429
for the rest of the window. Auth helper waitForURL then timed out at 45s with
no redirect ever firing — surfacing as 8 cascading test failures across
auth.spec.js, board.spec.js, and admin-members.spec.js.
The bypass mirrors the existing gate used by /api/dev/* endpoints: the env
var is opt-in and only set in development (.env) or by Playwright's
webServer config. Production never sets it, so rate limiting remains active.
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.
The HelcimPay modal loads from secure.helcim.app, but the CSP only
listed myposjs.helcim.com (script/connect) and secure.helcim.com
(frame, likely a stale typo). Add secure.helcim.app to script-src,
connect-src, and frame-src so the join flow's payment modal can load.
Build was failing on Dokploy when fonts.bunny.net was unreachable from the
build container. Fonts are already loaded at runtime via the explicit
<link rel="stylesheet"> in app.head, so the auto-resolver is redundant —
disable all external providers to remove the build-time network dependency.
validate-env.js now reads all four required vars (MONGODB_URI, JWT_SECRET,
RESEND_API_KEY, HELCIM_API_TOKEN) from useRuntimeConfig() instead of mixing
direct process.env reads with a JWT-only special case. Mongoose and the six
Resend instantiations follow suit. Either bare or NUXT_-prefixed env names
are accepted, so Dokploy no longer needs duplicate entries.
Route was the only DB-using endpoint that didn't call connectDB(); other
routes warm the connection incidentally, but on a freshly-booted container
with no SLACK_BOT_TOKEN the slack-joins plugin skips and nothing else
opens the pool — first reconcile request hung 10s on buffered Member.find()
and returned 500.
Dokploy wraps scheduled-task commands in `bash -c "..."` and the daily
reconcile cron uses curl. node:20-alpine ships neither — first manual
"Run now" failed with `exec: "bash": executable file not found in $PATH`.
apk add --no-cache bash curl adds ~5MB to the runtime image; trivial cost
for the cron use case.
The reconcile-payments cron POSTs to /api/internal/reconcile-payments with
an X-Reconcile-Token header but no csrf-token cookie/header. The CSRF
middleware was 403ing the request before the route handler could check
the shared secret — breaking Fix#6 (daily reconciliation cron).
Found while wiring the Dokploy scheduled task. The Netlify scheduled
function would have hit the same 403; nobody noticed because the site
hasn't been deployed yet.
Removing CSRF protection from /api/internal/ is safe: every route under
that prefix is machine-to-machine and gates on its own shared-secret
header. CSRF protects against browser-driven cross-origin POSTs, which
isn't the threat model for these endpoints.
Tests: 758 passing (CSRF middleware unit tests still cover the exempt
list shape).
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.
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.
When a series requires a pass and doesn't allow drop-ins, the
per-event availability endpoint returned a generic "No tickets
available" reason, leaving the UI to render an "Event Sold Out"
block for guests (logged-in users short-circuit via
check-series-access first).
Detect the gate server-side and return
{available:false, reason:"series_pass_required", requiresSeriesPass:true,
series:{id,title,slug}} so EventTicketPurchase's existing
requiresSeriesPass branch renders a pass-required CTA with a link to
the series page. The register and purchase handlers already enforce
the gate server-side; this is a messaging fix only.
Legacy events (tickets.enabled=false) with membersOnly=true were
returning a free guest ticket for unauthenticated callers, causing
GET /api/events/[id]/tickets/available to report available:true. The
UI then rendered the registration form and register.post.js 403'd on
submit. Short-circuit early when membersOnly && !hasMemberAccess so the
availability endpoint's existing null-ticketInfo branch surfaces the
correct "members only" reason.
Pre-fix (before f34b062 / 4e1888a) prod may contain drop-in
registrations on pass-only series events. Defer audit + remediation
until deploy time; local was scrubbed separately on 2026-04-20.