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.
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.
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.
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.
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.
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.
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.
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.
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).
- listHelcimCustomerTransactions(customerCode): GET /card-transactions/
with customerCode filter, sorts newest-first, caps at 50, normalizes
Helcim status (APPROVED/DECLINED) + type (refund) into
paid/refunded/failed/other.
- updateHelcimCustomerDefaultPaymentMethod(customerId, cardToken):
resolves cardToken -> cardId via /customers/{id}/cards, then PATCHes
/customers/{id}/cards/{cardId}/default.
- updateHelcimSubscriptionPaymentMethod(subscriptionId, cardToken):
wraps updateHelcimSubscription with a cardToken payload.
- helcimUpdateCardSchema: Zod schema { cardToken: string } for the
upcoming /api/helcim/update-card route.
- Unit tests for all three helpers (success + error paths).
helcimFetch called response.json() unconditionally, which threw
"Unexpected end of JSON input" on Helcim's 204 No Content responses
(e.g. DELETE /subscriptions/:id). The error was silently swallowed by
the best-effort cancel path in cancel-subscription, masking cases where
the Helcim-side cancel actually succeeded.
Moves updateHelcimSubscription to the live-verified wrapped shape
(PATCH /subscriptions { subscriptions: [{ id, ...payload }] }), adds a prior-
status check so sendWelcomeEmail only fires on pending_payment to active
transitions, short-circuits get-or-create-customer when a valid
helcimCustomerId is already on file, and replaces member.save() Slack-status
writes with findByIdAndUpdate({ runValidators: false }) to avoid save-time
validator pitfalls.
Extracts hasMemberAccess(member) in tickets.js and uses it across event
registration, ticket purchase, and series purchase flows so guest, suspended,
and cancelled records no longer count as members while pending_payment still
does.
Introduces /community-guidelines and /policies/{privacy,terms,[slug]} pages,
swaps the signup/invite checkbox from agreedToTerms to agreedToGuidelines,
adds Member.agreement.acceptedAt, and stamps the field when a Helcim
customer is created.
The /api/events/[id]/guest-register endpoint has no production
callers: it's superseded by tickets/purchase.post.js, which
handles guest Member upsert via status:"guest" when
body.createAccount is true. Drops the route file, its
source-assertion tests, guestRegisterSchema, and its validation
coverage.
Non-members who register for an event now get a persistent identity:
with consent, a status:"guest" Member is upserted and an auth cookie is
set so the "You're Registered" state survives a page refresh.
Tiered auto-login matches passwordless-auth norms — auto-login is only
safe when the account holds no privileges:
- New email → create guest + cookie
- Returning guest → cookie
- Existing non-guest (active/pending/etc.) → attach ticket only, no
cookie, confirmation email includes a sign-in link
Guests are gated on status === "guest", so admin/middleware code that
keys on status === "active" naturally excludes them. Guests are also
treated as non-members for ticket pricing/validation to prevent picking
up member-only pricing on their second registration.
Users clicking sign-out in the wiki were getting 'xsrf token invalid'.
The old logoutSource extracted the xsrf from oidc-provider's form into
a separate short-lived cookie and bounced through /auth/logout-confirm,
but that dance kept desyncing — the xsrf on the eventual submit didn't
always match the session state on /oidc/session/end/confirm.
Drop the custom confirmation page and auto-submit oidc-provider's own
form inline from logoutSource. The xsrf stays inside the original form
HTML the provider generated, so the validation is guaranteed to match.
Clicking sign-out in the wiki is already confirmation enough.
Also clear the Ghost Guild auth-token cookie in postLogoutSuccessSource
so signing out of the wiki fully signs the user out rather than leaving
a stale ghostguild.org session behind.
Members (and pre-registrants) hitting wiki.ghostguild.org were getting bounced
to /coming-soon with a "Pre-Register" link, even when the OIDC flow was
working correctly.
- Allowlist /auth/oidc-error, /auth/logout-confirm, /auth/logout-success,
and /verify in the coming-soon middleware so OIDC errors and main-site
magic links stop redirecting to the pre-register page.
- Raise OIDC Interaction TTL from 10m to 15m so it outlives the magic-link
JWT and legitimate members don't hit expired-interaction errors when they
click the email a few minutes late.
- Differentiate the "email isn't a registered member" response on the wiki
login route and show a dedicated "Not a member yet" state with a
pre-register link and contact email, instead of the misleading
"Check your inbox" that silently failed.
- Timezone: curated USelectMenu dropdown (app/config/timezones.js), preserves unknown saved values
- Profile save now uses useToast() for success/error; remove inline save banner
- Nav onboarding dot nudged down 1px for optical alignment with lowercase text
- Onboarding: skip a suggestion with POST /api/onboarding/track {skip}; member.onboarding.skipped map; does not affect graduation
- CirclePicker takes :saved-value so 'Current' badge stays until save completes
- PrivacyToggle is binary (USwitch labeled Private); member schema enum reduced to ['members','private']; zod coerces legacy 'public'
- New /member/payment-setup page: HelcimPay $0 verify + update-contribution, wired from account.vue via requiresPaymentSetup redirect
- Helcim portal: NUXT_PUBLIC_HELCIM_PORTAL_URL env + account.vue 'Manage billing in Helcim' link
- Migration script: scripts/migrate-privacy-public-to-members.js
- Delete server/api/members/me/board.patch.js and server/api/board/suggestions.get.js
- Add boardSlackHandle to memberProfileUpdateSchema; remove boardPrivacy
- profile.patch.js: write boardSlackHandle -> board.slackHandle; drop boardPrivacy
- Remove privacy.board field from Member model
- onboarding/status.get.js: hasProfileTags now requires only craftTags; hasEngagedBoard uses BoardPost.exists
- onboarding/track.post.js: graduation check uses BoardPost.exists instead of board.topics elemMatch
- members/[id].get.js and directory.get.js: reduce board response to slackHandle only; drop connectionTag and peerSupport filters
- Add BoardPost model (author, title, seeking/offering, note, tags) with
validator requiring at least one of seeking/offering
- Add BoardChannel model (name, slackChannelId, tagSlugs)
- Add boardPost/boardChannel create+update Zod schemas
- Trim Member.board subdoc to only slackHandle (drop topics, details,
offerPeerSupport, availability, personalMessage)
- Remove old boardUpdateSchema
Model, schemas, API routes, activity log, and all server handlers
updated. Old ecology/ and community-ecology routes removed, new
board/ routes added. Tests updated and new board-suggestions tests
written (10 cases).
Migrate three render callbacks in oidc-provider (logoutSource,
postLogoutSuccessSource, renderError) from the baked guildPageShell
helper to Nuxt pages under app/pages/auth/, so they go through the
font module and design system instead of a shadow copy.
- Delete guildPageShell (~103 lines of shadow design system).
- Add /auth/logout-success, /auth/oidc-error, /auth/logout-confirm
pages built on dashed-box + btn + main.css tokens.
- renderError now allow-lists error + error_description into query
params and lets Vue default interpolation escape them, closing an
XSS where OIDC error fields were concatenated into raw HTML.
- logoutSource extracts the xsrf from oidc-provider's stable form
output, sets it as an httpOnly 2-minute cookie, and redirects to
/auth/logout-confirm. The confirm page reads the cookie during SSR,
persists the value to useState, and clears the cookie so it's
strictly one-time use. Defensive fallback keeps the raw auto-submit
form if oidc-provider ever changes its form format.
- Fix form actions emitting http:// in production at the root cause:
oidc-provider extends Koa but calls super() with no args, so
app.proxy defaults to false and ctx.protocol ignores
X-Forwarded-Proto. Set _provider.proxy = true after construction;
remove the bogus proxy:true config key (silently ignored) and the
form.replace('http://', 'https://') symptom patch. Make the
x-forwarded-proto override in the catchall conditional on
production + missing header (was unconditional + dead code).
- Add site-wide .btn:focus-visible rule in main.css for WCAG 2.4.7.
Verified in browser: Brygada 1918 loads on all three pages, contrast
ratios pass AA in dark + light, XSS payload escapes to text nodes
only, Set-Cookie: Max-Age=0 enforces one-time xsrf use, no
horizontal overflow at 500px, no console errors.
Deduplicate tag validation and regex escaping into shared auto-imported
utils. Add tag validation to wiki patch/batch-tag routes. Remove
duplicate tags field from event schema.
The Skills Exchange + Peer Support feature was replaced by Community
Connections on 2026-04-05, but several files and code paths were left
in place as backward-compat. None are reachable from the live UI:
- usePeerSupport.js composable: not imported anywhere
- PeerSupportBadge.vue: not imported anywhere
- peer-support.vue: stub redirect with no incoming links
- /api/peer-support.get.js: only consumed by usePeerSupport
- /api/members/me/peer-support.patch.js: same
- profile.patch.js offering/lookingFor write branches: profile form
no longer sends these fields (only writes communityConnections.*)
- PEER_SUPPORT_ENABLED/DISABLED activity types and renderers: only
written by the deleted peer-support.patch endpoint. The activityText
formatter has a fallback for unknown types so existing records
still display ("peer support enabled" with a generic icon).
Tests updated to drop peerSupportUpdateSchema coverage and the
offering/lookingFor passthrough assertion.
schemas.js cleanup deferred — concurrent communityConnections →
communityEcology rename is in flight in the working tree.