Skip to content

feat: implement notification center, rate limiting, CSRF & CSP header… - #1044

Merged
Kingsman-99 merged 2 commits into
Stellar-split:mainfrom
whiteghostdev1:feat/issues-896-920-921-922
Oct 22, 2026
Merged

Kingsman-99 merged 2 commits into
Stellar-split:mainfrom
whiteghostdev1:feat/issues-896-920-921-922

Conversation

@whiteghostdev1

Copy link
Copy Markdown
Contributor

…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 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 #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

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
@vercel

vercel Bot commented Oct 21, 2026

Copy link
Copy Markdown

@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.

@drips-wave

drips-wave Bot commented Oct 21, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@Kingsman-99
Kingsman-99 merged commit bb13a90 into Stellar-split:main Oct 22, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants