oidc-provider's devInteractions is a quick-start scaffold that, when
enabled, mutates configuration.url to its own urlFor('interaction')
helper — emitting /interaction/UID instead of our /oidc/interaction/UID.
That made /oidc/auth redirect to a 404 in local dev and forced a stale
TODO entry. We already have our own interaction handler at
server/routes/oidc/interaction/[uid].get.ts, so devInteractions is
unnecessary; disabling it makes dev match prod and clears the
oidc-provider warning "your configuration is not in effect".
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.
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.
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.
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.
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.
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).
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.
Admin event create/update forms submit empty strings for unset numeric
and date fields (maxAttendees, registrationDeadline, ticket quantity,
early-bird pricing), which Zod was rejecting. Preprocess empty strings
to undefined so the existing optional/nullable validators accept them.
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.
The series-pass gate in register.post.js was checking
`series.allowIndividualEventTickets` at the top level, but the field
lives under `series.tickets.allowIndividualEventTickets` per the
Series schema. Top-level access was always undefined, so `!undefined`
always fired the pass check — blocking drop-in registration even when
an admin enabled `(requiresSeriesTicket=true, allowIndividualEventTickets=true)`.
The bug failed closed (overprotective), so no bypass was possible.
The existing test mirrored the bug by mocking the field at the top
level; updated the three mocks to nest it under `tickets` so the test
shape matches the real schema.
Pre-launch P0 fixes surfaced by docs/specs/events-functional-test-matrix.md
(Findings 1, 2, 3).
1. Series-pass bypass (Finding 1 / matrix S1 P3): register.post.js now
loads the linked Series when tickets.requiresSeriesTicket is set and
rejects drop-in registration unless series.allowIndividualEventTickets
is true or the user has a valid pass. Data-integrity 500 if the
referenced series is missing.
2. Hidden-event leak (Finding 2 / matrix E11): extract loadPublicEvent
into server/utils/loadEvent.js. All five public event endpoints
([id].get, register, tickets/available, tickets/reserve,
tickets/purchase) now go through the helper, which 404s when
isVisible === false and the requester is not an admin. Admin detection
uses a new non-throwing getOptionalMember() in server/utils/auth.js
(extracted from the pattern already inlined in api/auth/status.get.js).
3. Deadline enforcement + legacy pricing retirement (Finding 3 / matrix
E8): register.post.js and tickets/reserve.post.js delegate gating to
validateTicketPurchase (which already covers deadline, cancelled,
started, members-only, sold-out, and already-registered);
tickets/available.get.js gets an explicit registrationDeadline check.
Legacy pricing.paymentRequired 402 branch removed from register.post.js.
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.
Nested object with filesCanadianTaxes, middleInitial, confirmedAddress
(street/city/province/country/postalCode), setupCompletedAt. All
default to null/false so existing members read as 'not set up'.
Schema-only: no Zod, no API route, no UI. Phase 2 will build the
account-page preferences flow and bind to these fields without
needing a migration.
In the Case 1 (free→paid) branch of update-contribution, after the
subscription is created and the member row is updated, fetch the
newest paid Helcim transaction and upsert a Payment with
paymentType=cadence and sendConfirmation=true.
Paid→paid (Case 3) is intentionally NOT wired — no new transaction
occurs at amount change; the next recurring charge is captured by the
reconciliation script.
After a paid subscription is created and the Member row is flipped to
active, fetches the newest paid transaction from Helcim and upserts a
Payment row. Passes paymentType from the chosen cadence and
sendConfirmation: true.
Wrapped in try/catch: a logging failure here never breaks subscription
creation — the reconcile-helcim-payments script will pick up any
misses on the next run.
Takes a Member doc + a normalized Helcim transaction and inserts a
Payment row if helcimTransactionId is unseen. Maps helcim status
paid→success, refunded→refunded, failed→failed; skips 'other'.
opts.paymentType overrides the cadence fallback for mid-flight cadence
changes. opts.sendConfirmation triggers a Resend payment-confirmation
email ONLY on new inserts — swallows send failures so email trouble
cannot break the upstream payment flow.
The Resend template lives in server/emails/paymentConfirmation.js. It
is CRA-safe (charity name + 'not an official donation receipt / tax
receipts available later in 2026' disclaimer) so it can be used in
either Task 8 branch without copy changes.
Introduces the payments collection with fields Phase 2 will need to
generate official donation receipts: amount, paymentDate, paymentType
(monthly|annual), helcimTransactionId (unique), and receiptIssued /
receiptId placeholders Phase 2 will populate. Schema-only; no routes
or UI in this commit.
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).