fix: enforce CSRF session-binding, propagate refreshed token, server-deliver rate alerts (#486, #487, #469) - #672
Merged
therealjhay merged 10 commits intoAug 31, 2026
Conversation
verifyCsrfRequest() enforces the double-submit check (X-CSRF-Token == csrf_token cookie) and, when a csrf_sid binding cookie exists, that it equals sha256(csrf_token . sessionKey) — so a token minted for a different visitor / a pre-login page can't be replayed once a real session is in play. csrfHeader() lets same-origin fetch()s to our own API routes carry the token; rotateCsrfToken() forces a fresh cookie at identity-change moments. Compare is constant-time. Refs Betta-Pay#486
GET /api/auth/csrf?rotate=1 mints a brand-new token even when a valid one exists, so the client can force rotation after logout.
authStore.logout() calls rotateCsrfToken() after clearing the session so a token minted during the session just ended cannot linger for whoever uses the browser profile next. Acceptance: the token changes after logout, not only after login.
…ay#469) lib/server/rateAlerts.ts holds the per-merchant alert list plus evaluateAlerts(): condition + time-window match, per-alert delivery dedupe (DELIVERY_DEDUPE_MS), one-time alerts deactivate after firing, in-app deliveries land in the notification store immediately and email/webhook deliveries are queued for the worker.
…, Betta-Pay#486) GET/POST /api/rate-alerts now use lib/server/rateAlerts and validate recurrence, channels and an optional HH:MM time window. POST is CSRF-verified.
…ta-Pay#469, Betta-Pay#486) Share the server store; accept channel updates; clear the delivery dedupe marker when an alert is un-triggered.
…#469) POST /api/rate-alerts/evaluate { pair, rate } fires every matching alert, delivers on each configured channel, dedupes, deactivates one-time alerts, and returns the authoritative list for the client to reconcile. CSRF-verified.
…ay#469) addAlert optimistically inserts then POSTs and swaps in the server id; toggle/delete propagate to the API for synced rows; evaluate() asks the server to fire alerts and reconciles from its response. Alerts now survive a reload because hydrateFromServer restores them.
The create-alert form gains one-time/recurring, in-app/email/webhook channels and an optional fire-only-within window. The page hydrates alerts from the backend on mount and routes trigger evaluation through the server (with the existing client check as an offline fallback). Alert rows show frequency, channels and window.
…ta-Pay#487) POST /api/auth/refresh returns the new access token on a real rotation; the axios single-flight path and the SessionTimeoutModal's extend action now push it into the auth store so the request interceptor's local JWT pre-check stops rejecting follow-up requests against the stale token. A 401 from refresh (dead refresh token) logs out and redirects exactly once, without toast spam.
|
@stephanieoghenemega-eng is attempting to deploy a commit to the therealjhay's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
@stephanieoghenemega-eng Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves #486, Resolves #487, Resolves #469 — a full pass over CSRF session-binding, real access-token refresh, and server-backed FX rate alerts.
#486 — CSRF is now session-bound and enforced
verifyCsrfRequest()(lib/utils/csrf.ts) enforces two checks on every state-changing request to our own API routes:X-CSRF-Tokenheader must equal thecsrf_tokencookie (constant-time compare);csrf_sidcookie exists it must equalsha256(csrf_token . sessionKey(auth_token)). Acsrf_tokenminted for a different visitor or a pre-login page carries the wrongcsrf_sid, so it is rejected once a real session is in play. Sessions predating the binding fall back to double-submit only and are upgraded on their next login/refresh (no lock-out).POST /api/rate-alerts,PATCH/DELETE /api/rate-alerts/:id,POST /api/rate-alerts/evaluate.authStore.logout()callsrotateCsrfToken()(GET /api/auth/csrf?rotate=1) after clearing the session, so the token changes after logout, not only after login.POST /api/auth/session) and refresh (POST /api/auth/refresh) already rotate bothcsrf_tokenandcsrf_sidtogether.Verify: capture
csrf_tokenbefore login → log in → it changes andcsrf_sidappears. Log out → it changes again. Set the oldcsrf_tokencookie + header by hand andPOST /api/rate-alerts→403 CSRF validation failed.#487 — the refresh route's new token now reaches the client
POST /api/auth/refreshreturns{ refreshed, token }on a real rotation. Previously nothing consumedtoken, so the axios request interceptor's local JWT-expiry pre-check kept rejecting follow-up requests against the stale in-memory token.useAuthStore.getState().setToken(token)before replaying the queued requests.SessionTimeoutModal→extendSession()does the same, and a401from refresh (dead refresh token) logs out + redirects once — ahasRedirectedForExpiryguard stops a burst of queued 401s each firing their own redirect + toast.Verify: network tab — "Extend session" shows
/api/auth/refreshreturning a newtoken; the next API call carries the rotated cookie and succeeds. Kill the refresh token server-side → one redirect to/auth/login, one toast at most.#469 — rate alerts are server-backed with real delivery
lib/server/rateAlerts.ts— shared per-merchant store +evaluateAlerts(): condition match, optionalHH:MMtime window (wraps past midnight), per-alert delivery dedupe (DELIVERY_DEDUPE_MS), one-time alerts deactivate after firing, recurring re-arm. In-app deliveries land in the notification store immediately (with its existing dedupe); email/webhook deliveries are recorded asqueuedfor the feature-AdduseCallbackfor event handlers in dashboard and settings #38 worker.POST /api/rate-alerts/evaluate { pair, rate }— the FX page feeds the live rate; the server fires + delivers + dedupes and returns the authoritative list.rateAlertStore—addAlertoptimistically inserts thenPOSTs and swaps in the server id; toggle/delete propagate for synced rows;hydrateFromServer()restores alerts on boot so they survive a reload;evaluate()reconciles from the server.triggeredfor synced rows).Verify: set an alert, reload → it's still there (from
GET /api/rate-alerts). Move the rate past the target → one delivery (in-app notification appears once even across repeated evaluations); a one-time alert flips to disabled.Notes
globalThispattern; swap for tables before production.npm run build/ the jest suites were not run in this environment; changes follow existing patterns and the store/hook unit tests' contracts (syncaddAlert,extendSessionreturn shape) are preserved.🤖 Generated with Claude Code