Repository navigation
feat: implement notification center, rate limiting, CSRF & CSP header… - #1044
Merged
Kingsman-99 merged 2 commits intoOct 22, 2026
Merged
Conversation
Stellar-split#896 Stellar-split#920 Stellar-split#921 Stellar-split#922) This commit implements four security and UX issues simultaneously. Below is a detailed breakdown of each issue, what was changed, and why. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Stellar-split#896 — Notification Center with email and in-app notifications ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Problem: The NotificationCenter component had duplicate const declarations for `unread` and `badgeCount` (TypeScript compile error), referenced `persistMarkAllRead()` which was undefined, and was missing the `reminder` notification type that notifications/page.tsx expected. The settings page only exposed in-app and browser toggles — no email preference. The emailer had no real sending logic. Changes: - src/components/NotificationCenter.tsx · Added `reminder` to NotificationType union and TYPE_ICONS map · Removed duplicate variable declarations (compile fix) · Defined persistMarkAllRead() — POSTs to /api/notifications/mark-read so read-state can be persisted server-side with CSRF protection · Imported apiFetch from @/lib/apiClient for CSRF-safe calls - src/app/settings/notifications/page.tsx · Added `email: boolean` to EventPrefs interface and DEFAULT_PREFS · Widened table grid from 2 to 3 toggle columns (In-App | Browser | Email) · Email toggle shows a tooltip directing users to configure RESEND_API_KEY · Persists email preference to localStorage alongside inApp/browser prefs - src/app/api/notifications/mark-read/route.ts [NEW] · POST endpoint that acknowledges mark-all-read operations · Protected by assertCsrf() — requires valid CSRF token - src/app/api/notifications/send-email/route.ts [NEW] · POST endpoint for sending transactional notification emails via Resend · Validates input (to, subject, message), checks email format · Gracefully degrades (skips with 200 ok + skipped:true) when RESEND_API_KEY is not set so dev environments work without config · Protected by assertCsrf() - src/lib/paymentReceiptEmailer.ts · Replaced TODO scaffold with real Resend HTTP call · Builds styled HTML receipt with amount, date, payer address, tx hash · Includes a 'View Invoice' CTA linked to the invoice page · Same graceful degradation as the route above when key is absent · Idempotency: checks/records sent state via checkIfEmailSent / markEmailAsSent before and after each send Closes Stellar-split#896 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Stellar-split#920 — Rate limiting for payment operations ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Problem: The app had client-side 429 retry logic (api.ts) and a RateLimitContext for countdown UI, but NO server-side rate limiter — any client could bypass the client-side guard and hammer payment endpoints directly. Approach: In-memory sliding-window limiter (no Redis dependency) keyed by wallet address (X-Wallet-Address header) with IP fallback. 10 payment attempts per 60-second window per identity. Whitelist via env var. Changes: - src/lib/rateLimit.ts [NEW] · checkRateLimit(identity, config) — sliding window implementation · getRequestIdentity(request) — extracts wallet address or forwarded IP · pruneRateLimitStore() — prevents unbounded memory growth · PAYMENT_RATE_LIMIT config: 10 req / 60 s · QUERY_RATE_LIMIT config: 120 req / 60 s · RATE_LIMIT_WHITELIST env var for integration test / admin bypass - src/lib/middleware/rateLimitMiddleware.ts [NEW] · applyRateLimit(request, config) — drop-in guard for route handlers · Returns 429 with Retry-After + X-RateLimit-* standard headers on breach · Returns null (allowed) with rate limit headers to set on success responses - src/app/api/rate-limit/status/route.ts [NEW] · GET /api/rate-limit/status — lets the frontend query remaining budget · Used by RateLimitContext to show countdown without waiting for a 429 - src/app/api/invoices/[id]/payment-confirmed/route.ts · Added applyRateLimit(request, PAYMENT_RATE_LIMIT) guard at handler entry - src/app/api/invoices/[id]/installments/[index]/mark-paid/route.ts · Added applyRateLimit(request, PAYMENT_RATE_LIMIT) guard at handler entry - src/app/api/settlement/execute/route.ts · Added applyRateLimit(request, PAYMENT_RATE_LIMIT) guard at handler entry Closes Stellar-split#920 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Stellar-split#921 — CSRF protection for state-changing operations ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Problem: The CSRF system (csrf.ts, apiClient.ts, csrfMiddleware.ts) was fully implemented but two gaps remained: (1) getCsrfSecret() threw an error in development when CSRF_SECRET was not set, making local dev require manual env setup, and (2) no documentation existed for the mechanism. Changes: - src/lib/csrf.ts · getCsrfSecret() now falls back to a fixed insecure dev secret when NODE_ENV !== 'production' and CSRF_SECRET is unset, with a console.warn · In production the hard throw is preserved — misconfiguration is fatal - docs/CSRF_PROTECTION.md [NEW] · Full explanation of the double-submit HMAC token approach · Token lifecycle, refresh strategy, retry-on-403 behaviour · How to protect a new route (code example) · Environment variable reference (CSRF_SECRET, generate with openssl) · Notes on SameSite cookie complementary defence Closes Stellar-split#921 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Stellar-split#922 — Content Security Policy headers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Problem: The middleware built a dynamic connectSrc list from env vars (analytics, backup webhook origins) and passed it to buildCSP(), but BuildCSPOptions only accepted allowFraming — the connectSrc parameter was silently dropped, meaning custom origins were never included in the policy. There was also no mechanism to receive CSP violation reports from browsers. Changes: - src/lib/csp.ts · Added connectSrc?: string[] to BuildCSPOptions · buildCSP() now merges provided origins with hardcoded Stellar origins and deduplicates them via Set before building the directive string · Added reportUri?: string to BuildCSPOptions · When set, appends report-uri <uri> as the final directive - src/middleware.ts · Added getCspReportUri() — reads CSP_REPORT_URI env var · Passes reportUri to buildCSP() so violation reporting is opt-in via env - src/app/api/csp-report/route.ts [NEW] · POST /api/csp-report — receives browser violation reports · Parses both wrapped ({csp-report: {...}}) and unwrapped payloads · Logs structured JSON for monitoring / alerting pipelines · Always returns 204 (browsers don't use the response body) - .env.example · Added CSP_REPORT_URI, RATE_LIMIT_WHITELIST, EMAIL_FROM, RESEND_API_KEY with descriptive comments - docs/CSP_HEADERS.md [NEW] · Policy directive table with values and rationale for each · Nonce flow diagram (middleware → Server Component → inline tag) · Configuration reference for all relevant env vars · Violation reporting setup instructions Closes Stellar-split#922
|
@whiteghostdev1 is attempting to deploy a commit to the kingsman-99's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
@whiteghostdev1 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.
…s (#896 #920 #921 #922)
This commit implements four security and UX issues simultaneously. Below is a detailed breakdown of each issue, what was changed, and why.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #896 — Notification Center with email and in-app notifications ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Problem: The NotificationCenter component had duplicate const declarations for
unreadandbadgeCount(TypeScript compile error), referencedpersistMarkAllRead()which was undefined, and was missing theremindernotification type that notifications/page.tsx expected. The settings page only exposed in-app and browser toggles — no email preference. The emailer had no real sending logic.Changes:
src/components/NotificationCenter.tsx · Added
reminderto NotificationType union and TYPE_ICONS map · Removed duplicate variable declarations (compile fix) · Defined persistMarkAllRead() — POSTs to /api/notifications/mark-read so read-state can be persisted server-side with CSRF protection · Imported apiFetch from @/lib/apiClient for CSRF-safe callssrc/app/settings/notifications/page.tsx · Added
email: booleanto EventPrefs interface and DEFAULT_PREFS · Widened table grid from 2 to 3 toggle columns (In-App | Browser | Email) · Email toggle shows a tooltip directing users to configure RESEND_API_KEY · Persists email preference to localStorage alongside inApp/browser prefssrc/app/api/notifications/mark-read/route.ts [NEW] · POST endpoint that acknowledges mark-all-read operations · Protected by assertCsrf() — requires valid CSRF token
src/app/api/notifications/send-email/route.ts [NEW] · POST endpoint for sending transactional notification emails via Resend · Validates input (to, subject, message), checks email format · Gracefully degrades (skips with 200 ok + skipped:true) when RESEND_API_KEY is not set so dev environments work without config · Protected by assertCsrf()
src/lib/paymentReceiptEmailer.ts · Replaced TODO scaffold with real Resend HTTP call · Builds styled HTML receipt with amount, date, payer address, tx hash · Includes a 'View Invoice' CTA linked to the invoice page · Same graceful degradation as the route above when key is absent · Idempotency: checks/records sent state via checkIfEmailSent / markEmailAsSent before and after each send
Closes #896
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #920 — Rate limiting for payment operations
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Problem: The app had client-side 429 retry logic (api.ts) and a RateLimitContext for countdown UI, but NO server-side rate limiter — any client could bypass the client-side guard and hammer payment endpoints directly.
Approach: In-memory sliding-window limiter (no Redis dependency) keyed by wallet address (X-Wallet-Address header) with IP fallback. 10 payment attempts per 60-second window per identity. Whitelist via env var.
Changes:
src/lib/rateLimit.ts [NEW] · checkRateLimit(identity, config) — sliding window implementation · getRequestIdentity(request) — extracts wallet address or forwarded IP · pruneRateLimitStore() — prevents unbounded memory growth · PAYMENT_RATE_LIMIT config: 10 req / 60 s · QUERY_RATE_LIMIT config: 120 req / 60 s · RATE_LIMIT_WHITELIST env var for integration test / admin bypass
src/lib/middleware/rateLimitMiddleware.ts [NEW] · applyRateLimit(request, config) — drop-in guard for route handlers · Returns 429 with Retry-After + X-RateLimit-* standard headers on breach · Returns null (allowed) with rate limit headers to set on success responses
src/app/api/rate-limit/status/route.ts [NEW] · GET /api/rate-limit/status — lets the frontend query remaining budget · Used by RateLimitContext to show countdown without waiting for a 429
src/app/api/invoices/[id]/payment-confirmed/route.ts · Added applyRateLimit(request, PAYMENT_RATE_LIMIT) guard at handler entry
src/app/api/invoices/[id]/installments/[index]/mark-paid/route.ts · Added applyRateLimit(request, PAYMENT_RATE_LIMIT) guard at handler entry
src/app/api/settlement/execute/route.ts · Added applyRateLimit(request, PAYMENT_RATE_LIMIT) guard at handler entry
Closes #920
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #921 — CSRF protection for state-changing operations ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Problem: The CSRF system (csrf.ts, apiClient.ts, csrfMiddleware.ts) was fully implemented but two gaps remained: (1) getCsrfSecret() threw an error in development when CSRF_SECRET was not set, making local dev require manual env setup, and (2) no documentation existed for the mechanism.
Changes:
src/lib/csrf.ts · getCsrfSecret() now falls back to a fixed insecure dev secret when NODE_ENV !== 'production' and CSRF_SECRET is unset, with a console.warn · In production the hard throw is preserved — misconfiguration is fatal
docs/CSRF_PROTECTION.md [NEW] · Full explanation of the double-submit HMAC token approach · Token lifecycle, refresh strategy, retry-on-403 behaviour · How to protect a new route (code example) · Environment variable reference (CSRF_SECRET, generate with openssl) · Notes on SameSite cookie complementary defence
Closes #921
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #922 — Content Security Policy headers
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Problem: The middleware built a dynamic connectSrc list from env vars (analytics, backup webhook origins) and passed it to buildCSP(), but BuildCSPOptions only accepted allowFraming — the connectSrc parameter was silently dropped, meaning custom origins were never included in the policy. There was also no mechanism to receive CSP violation reports from browsers.
Changes:
src/lib/csp.ts · Added connectSrc?: string[] to BuildCSPOptions · buildCSP() now merges provided origins with hardcoded Stellar origins and deduplicates them via Set before building the directive string · Added reportUri?: string to BuildCSPOptions · When set, appends report-uri as the final directive
src/middleware.ts · Added getCspReportUri() — reads CSP_REPORT_URI env var · Passes reportUri to buildCSP() so violation reporting is opt-in via env
src/app/api/csp-report/route.ts [NEW] · POST /api/csp-report — receives browser violation reports · Parses both wrapped ({csp-report: {...}}) and unwrapped payloads · Logs structured JSON for monitoring / alerting pipelines · Always returns 204 (browsers don't use the response body)
.env.example · Added CSP_REPORT_URI, RATE_LIMIT_WHITELIST, EMAIL_FROM, RESEND_API_KEY with descriptive comments
docs/CSP_HEADERS.md [NEW] · Policy directive table with values and rationale for each · Nonce flow diagram (middleware → Server Component → inline tag) · Configuration reference for all relevant env vars · Violation reporting setup instructions
Closes #922