From ee45400c7cb86ad65bb3678d02f25f51fc105471 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 14:43:46 +0000 Subject: [PATCH 1/7] fix: update vite to 6.4.2 to patch high-severity vulnerabilities Vite 6.4.1 has two high-severity vulnerabilities: - GHSA-4w7w-66w2-5vf9: Path Traversal in Optimized Deps .map Handling - GHSA-p9ff-h696-f583: Arbitrary File Read via Vite Dev Server WebSocket Updated to vite 6.4.2. Fixes CAR-599. Co-Authored-By: Paperclip --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a56c4d4..709106e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9805,9 +9805,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "devOptional": true, "license": "MIT", "dependencies": { From 121dc5724efa574be02050812996de71ac2e205e Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 15:37:24 +0000 Subject: [PATCH 2/7] fix: remove VITE_MOCK_AUTH bypass from production code Co-Authored-By: Paperclip --- e2e/journeys/j1-registration-login.spec.ts | 1 - playwright.config.ts | 2 +- src/components/ProtectedRoute.tsx | 17 ----------------- src/pages/Login.tsx | 9 +-------- src/pages/Register.tsx | 9 +-------- 5 files changed, 3 insertions(+), 35 deletions(-) diff --git a/e2e/journeys/j1-registration-login.spec.ts b/e2e/journeys/j1-registration-login.spec.ts index ec116ab..b1b28a4 100644 --- a/e2e/journeys/j1-registration-login.spec.ts +++ b/e2e/journeys/j1-registration-login.spec.ts @@ -10,7 +10,6 @@ test.describe('J1: Registration and Login', () => { await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!'); await page.click('button[type="submit"]'); - // With VITE_MOCK_AUTH=true the app navigates to "/" on success await expect(page).toHaveURL('http://localhost:5173/'); await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible(); }); diff --git a/playwright.config.ts b/playwright.config.ts index b22d74a..a2d7b0b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, ], webServer: { - command: 'VITE_MOCK_AUTH=true npm run dev', + command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: !process.env.CI, }, diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index cf92831..294ec4f 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,25 +1,8 @@ -import { useEffect } from 'react' import { Navigate, Outlet } from 'react-router-dom' import { authClient } from '../lib/auth-client.ts' -import { useAuthStore } from '../stores/auth.ts' export function ProtectedRoute() { - const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true' const { data: session, isPending } = authClient.useSession() - const isAuthenticated = useAuthStore((s) => s.isAuthenticated) - const setAuthenticated = useAuthStore((s) => s.setAuthenticated) - - useEffect(() => { - if (!isMockAuth) { - setAuthenticated(!!session) - } - }, [session, setAuthenticated, isMockAuth]) - - // In mock auth mode, rely on Zustand store (set by Login/Register pages) - if (isMockAuth) { - if (!isAuthenticated) return - return - } if (isPending) { return ( diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index ae7fc0c..5044613 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { authClient } from '../lib/auth-client.ts' -import { useAuthStore } from '../stores/auth.ts' export function Login() { const [email, setEmail] = useState('') @@ -9,7 +8,6 @@ export function Login() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) const navigate = useNavigate() - const setAuthenticated = useAuthStore((s) => s.setAuthenticated) async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -40,12 +38,7 @@ export function Login() { setError('Sign in failed. Please try again.') } } catch { - if (import.meta.env.VITE_MOCK_AUTH === 'true') { - setAuthenticated(true) - navigate('/') - } else { - setError('Invalid email or password. Please try again.') - } + setError('Invalid email or password. Please try again.') } finally { setLoading(false) } diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index c75e2d6..960aa0a 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { authClient } from '../lib/auth-client.ts' -import { useAuthStore } from '../stores/auth.ts' export function Register() { const [name, setName] = useState('') @@ -10,7 +9,6 @@ export function Register() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) const navigate = useNavigate() - const setAuthenticated = useAuthStore((s) => s.setAuthenticated) async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -48,12 +46,7 @@ export function Register() { setError('Account created! Please sign in.') } } catch { - if (import.meta.env.VITE_MOCK_AUTH === 'true') { - setAuthenticated(true) - navigate('/') - } else { - setError('Registration failed. Please try again.') - } + setError('Registration failed. Please try again.') } finally { setLoading(false) } From 4c217757c3722eca8a546c38d795096a9ed8c6ec Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 15:46:52 +0000 Subject: [PATCH 3/7] feat: Redis-backed rate limiting with stricter auth limits - Add rate_limit_auth_requests (5/min) and rate_limit_auth_window_seconds (60) settings to config.py - Refactor rate_limit.py to use protocol/ABC pattern with InMemorySlidingWindow and RedisSlidingWindow implementations - Add RedisSlidingWindow using sorted sets for distributed rate limiting - Add auth_strict_limiter for /auth/* POST endpoints (5 req/min per IP) - Fall back to in-memory when Redis is unavailable - Update tests to cover new functionality Co-Authored-By: Paperclip --- api/src/cartsnitch_api/config.py | 7 +- .../cartsnitch_api/middleware/rate_limit.py | 158 +++++++++++++-- api/tests/test_middleware/test_rate_limit.py | 185 +++++++++++++----- 3 files changed, 277 insertions(+), 73 deletions(-) diff --git a/api/src/cartsnitch_api/config.py b/api/src/cartsnitch_api/config.py index da68fe6..7fd10f9 100644 --- a/api/src/cartsnitch_api/config.py +++ b/api/src/cartsnitch_api/config.py @@ -33,6 +33,9 @@ class Settings(BaseSettings): rate_limit_requests: int = 60 rate_limit_window_seconds: int = 60 rate_limit_enabled: bool = True + rate_limit_auth_requests: int = 5 + rate_limit_auth_window_seconds: int = 60 + rate_limit_redis_enabled: bool = True _PLACEHOLDER_VALUES = {"change-me-in-production"} @@ -72,7 +75,9 @@ class Settings(BaseSettings): def normalize_database_url(self): """Normalize postgresql:// → postgresql+asyncpg:// for the asyncpg driver.""" if self.database_url.startswith("postgresql://"): - self.database_url = self.database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + self.database_url = self.database_url.replace( + "postgresql://", "postgresql+asyncpg://", 1 + ) return self diff --git a/api/src/cartsnitch_api/middleware/rate_limit.py b/api/src/cartsnitch_api/middleware/rate_limit.py index 319b363..fd4fdbc 100644 --- a/api/src/cartsnitch_api/middleware/rate_limit.py +++ b/api/src/cartsnitch_api/middleware/rate_limit.py @@ -4,19 +4,35 @@ Uses in-memory sliding window as fallback, Redis/DragonflyDB when available. Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints. """ +import asyncio import hashlib +import logging import time +import uuid from collections import defaultdict from threading import Lock +from typing import Protocol, runtime_checkable +import redis.asyncio as redis from fastapi import FastAPI, Request, status from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from cartsnitch_api.config import settings +logger = logging.getLogger(__name__) -class _SlidingWindowCounter: + +@runtime_checkable +class RateLimiter(Protocol): + """Protocol for rate limiter implementations.""" + + async def is_allowed(self, key: str) -> tuple[bool, int, int]: + """Check if request is allowed. Returns (allowed, remaining, retry_after).""" + ... + + +class InMemorySlidingWindow: """Thread-safe in-memory sliding window rate limiter.""" def __init__(self, max_requests: int, window_seconds: int) -> None: @@ -25,13 +41,12 @@ class _SlidingWindowCounter: self._hits: dict[str, list[float]] = defaultdict(list) self._lock = Lock() - def is_allowed(self, key: str) -> tuple[bool, int, int]: + async def is_allowed(self, key: str) -> tuple[bool, int, int]: """Check if request is allowed. Returns (allowed, remaining, retry_after).""" now = time.monotonic() cutoff = now - self.window_seconds with self._lock: - # Prune expired entries self._hits[key] = [t for t in self._hits[key] if t > cutoff] current_count = len(self._hits[key]) @@ -44,15 +59,101 @@ class _SlidingWindowCounter: return True, remaining, 0 -# Module-level counters — one for public (per-IP), one for auth (per-token) -_public_limiter = _SlidingWindowCounter( - max_requests=settings.rate_limit_requests, - window_seconds=settings.rate_limit_window_seconds, -) -_auth_limiter = _SlidingWindowCounter( - max_requests=settings.rate_limit_requests * 5, # 300/min for authenticated users - window_seconds=settings.rate_limit_window_seconds, -) +class RedisSlidingWindow: + """Redis-backed sliding window rate limiter using sorted sets.""" + + def __init__(self, client: redis.Redis, max_requests: int, window_seconds: int) -> None: + self.client = client + self.max_requests = max_requests + self.window_seconds = window_seconds + + async def is_allowed(self, key: str) -> tuple[bool, int, int]: + """Check if request is allowed using Redis sorted sets. Returns (allowed, remaining, retry_after).""" + now_ms = int(time.time() * 1000) + window_ms = self.window_seconds * 1000 + cutoff = now_ms - window_ms + + try: + async with self.client.pipeline(transaction=True) as pipe: + pipe.zremrangebyscore(key, 0, cutoff) + pipe.zcard(key) + await pipe.execute() + + current_count = await self.client.zcard(key) + + if current_count >= self.max_requests: + results = await self.client.zrange(key, 0, 0, withscores=True) + if results: + oldest_score = int(results[0][1]) + retry_after = int((oldest_score - cutoff) / 1000) + 1 + else: + retry_after = self.window_seconds + return False, 0, retry_after + + member = f"{now_ms}:{uuid.uuid4().hex[:8]}" + async with self.client.pipeline(transaction=True) as pipe: + pipe.zadd(key, {member: now_ms}) + pipe.expire(key, self.window_seconds) + await pipe.execute() + + remaining = self.max_requests - current_count - 1 + return True, remaining, 0 + + except Exception as e: + logger.warning(f"Redis rate limit error, falling back to in-memory: {e}") + raise + + +_redis_client: redis.Redis | None = None +_use_redis = False + + +def _get_limiters() -> tuple[RateLimiter, RateLimiter, RateLimiter]: + """Get the three rate limiters (public, auth, auth_strict).""" + global _redis_client, _use_redis + + if _use_redis and _redis_client is not None: + return ( + RedisSlidingWindow( + _redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds + ), + RedisSlidingWindow( + _redis_client, settings.rate_limit_requests * 5, settings.rate_limit_window_seconds + ), + RedisSlidingWindow( + _redis_client, + settings.rate_limit_auth_requests, + settings.rate_limit_auth_window_seconds, + ), + ) + return ( + InMemorySlidingWindow(settings.rate_limit_requests, settings.rate_limit_window_seconds), + InMemorySlidingWindow(settings.rate_limit_requests * 5, settings.rate_limit_window_seconds), + InMemorySlidingWindow( + settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds + ), + ) + + +def _init_redis() -> None: + """Initialize Redis connection at module load.""" + global _redis_client, _use_redis + + if not settings.rate_limit_redis_enabled: + logger.info("Redis rate limiting disabled via config") + return + + try: + _redis_client = redis.from_url(settings.redis_url) + asyncio.get_event_loop().run_until_complete(_redis_client.ping()) + _use_redis = True + logger.info("Redis rate limiting enabled") + except Exception as e: + logger.warning(f"Redis unavailable for rate limiting, using in-memory: {e}") + _use_redis = False + + +_init_redis() def _get_client_ip(request: Request) -> str: @@ -63,30 +164,45 @@ def _get_client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" -def _get_rate_limit_key(request: Request) -> tuple[str, _SlidingWindowCounter]: +def _get_rate_limit_key(request: Request) -> tuple[str, RateLimiter]: """Determine rate limit key and which limiter to use.""" - if request.url.path.startswith("/public"): - return f"ip:{_get_client_ip(request)}", _public_limiter + public_limiter, auth_limiter, auth_strict_limiter = _get_limiters() + + if request.url.path.startswith("/public"): + return f"ip:{_get_client_ip(request)}", public_limiter + + if request.url.path.startswith("/auth/") and request.method == "POST": + return f"ip:{_get_client_ip(request)}", auth_strict_limiter - # For authenticated endpoints, use Bearer token as key if present auth_header = request.headers.get("authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] token_hash = hashlib.sha256(token.encode()).hexdigest() - return f"token:{token_hash}", _auth_limiter + return f"token:{token_hash}", auth_limiter - # Fallback to IP for unauthenticated non-public endpoints - return f"ip:{_get_client_ip(request)}", _public_limiter + return f"ip:{_get_client_ip(request)}", public_limiter class RateLimitMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): - # Skip rate limiting when disabled (e.g. in tests) or for health checks if not settings.rate_limit_enabled or request.url.path == "/health": return await call_next(request) key, limiter = _get_rate_limit_key(request) - allowed, remaining, retry_after = limiter.is_allowed(key) + + try: + allowed, remaining, retry_after = await limiter.is_allowed(key) + except Exception: + public_limiter, auth_limiter, _ = _get_limiters() + if request.url.path.startswith("/auth/") and request.method == "POST": + limiter = auth_limiter + elif request.url.path.startswith("/public"): + limiter = public_limiter + elif request.headers.get("authorization", "").startswith("Bearer "): + limiter = auth_limiter + else: + limiter = public_limiter + allowed, remaining, retry_after = await limiter.is_allowed(key) if not allowed: return JSONResponse( diff --git a/api/tests/test_middleware/test_rate_limit.py b/api/tests/test_middleware/test_rate_limit.py index 59386a1..fad69fd 100644 --- a/api/tests/test_middleware/test_rate_limit.py +++ b/api/tests/test_middleware/test_rate_limit.py @@ -1,52 +1,157 @@ """Tests for rate limiting middleware.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter, _get_rate_limit_key +from cartsnitch_api.config import settings +from cartsnitch_api.middleware.rate_limit import ( + InMemorySlidingWindow, + RateLimitMiddleware, + _get_client_ip, + _get_rate_limit_key, + _init_redis, + _use_redis, +) -class TestSlidingWindowCounter: +class TestInMemorySlidingWindow: def test_allows_within_limit(self): - counter = _SlidingWindowCounter(max_requests=5, window_seconds=60) + limiter = InMemorySlidingWindow(max_requests=5, window_seconds=60) for i in range(5): - allowed, remaining, retry = counter.is_allowed("test-key") + allowed, remaining, retry = limiter.is_allowed("test-key") assert allowed is True assert remaining == 4 - i def test_blocks_over_limit(self): - counter = _SlidingWindowCounter(max_requests=3, window_seconds=60) + limiter = InMemorySlidingWindow(max_requests=3, window_seconds=60) for _ in range(3): - counter.is_allowed("test-key") + limiter.is_allowed("test-key") - allowed, remaining, retry = counter.is_allowed("test-key") + allowed, remaining, retry = limiter.is_allowed("test-key") assert allowed is False assert remaining == 0 assert retry > 0 def test_separate_keys(self): - counter = _SlidingWindowCounter(max_requests=2, window_seconds=60) - # Fill key-a - counter.is_allowed("key-a") - counter.is_allowed("key-a") - allowed_a, _, _ = counter.is_allowed("key-a") + limiter = InMemorySlidingWindow(max_requests=2, window_seconds=60) + limiter.is_allowed("key-a") + limiter.is_allowed("key-a") + allowed_a, _, _ = limiter.is_allowed("key-a") assert allowed_a is False - # key-b should still be allowed - allowed_b, remaining, _ = counter.is_allowed("key-b") + allowed_b, remaining, _ = limiter.is_allowed("key-b") assert allowed_b is True assert remaining == 1 -@pytest.mark.asyncio -async def test_rate_limit_returns_429(client): - """Public endpoint should return 429 after limit exceeded.""" - # The default limit is 60/min — we won't hit it in normal tests, - # but we verify the middleware adds rate limit headers. - resp = await client.get("/public/inflation") - assert "x-ratelimit-limit" in resp.headers - assert "x-ratelimit-remaining" in resp.headers +class TestGetRateLimitKey: + def _make_request( + self, + path: str = "/purchases", + method: str = "GET", + auth_header: str = "", + headers: dict | None = None, + ) -> MagicMock: + req = MagicMock() + req.url.path = path + req.method = method + req.headers = dict(headers) if headers else {} + if auth_header: + req.headers["authorization"] = auth_header + return req + + def test_public_path_uses_public_limiter(self): + req = self._make_request("/public/inflation") + key, limiter = _get_rate_limit_key(req) + assert key.startswith("ip:") + assert limiter.max_requests == settings.rate_limit_requests + + def test_auth_post_path_uses_strict_limiter(self): + req = self._make_request("/auth/login", method="POST") + key, limiter = _get_rate_limit_key(req) + assert key.startswith("ip:") + assert limiter.max_requests == settings.rate_limit_auth_requests + assert limiter.window_seconds == settings.rate_limit_auth_window_seconds + + def test_auth_get_path_uses_auth_limiter(self): + req = self._make_request("/auth/me", method="GET") + key, limiter = _get_rate_limit_key(req) + assert key.startswith("ip:") + assert limiter.max_requests == settings.rate_limit_requests * 5 + + def test_authenticated_token_uses_auth_limiter(self): + req = self._make_request("/purchases", auth_header="Bearer token123") + key, limiter = _get_rate_limit_key(req) + assert key.startswith("token:") + assert limiter.max_requests == settings.rate_limit_requests * 5 + + def test_distinct_tokens_produce_distinct_keys(self): + req1 = self._make_request("/purchases", auth_header="Bearer token_alpha_12345") + req2 = self._make_request("/purchases", auth_header="Bearer token_beta_67890") + key1, _ = _get_rate_limit_key(req1) + key2, _ = _get_rate_limit_key(req2) + assert key1 != key2 + + def test_same_token_produces_same_key(self): + req1 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc") + req2 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc") + key1, _ = _get_rate_limit_key(req1) + key2, _ = _get_rate_limit_key(req2) + assert key1 == key2 + + def test_key_does_not_contain_raw_token_suffix(self): + raw_token = "my_secret_jwt_token_xyz" + req = self._make_request("/purchases", auth_header=f"Bearer {raw_token}") + key, _ = _get_rate_limit_key(req) + assert raw_token[-16:] not in key + assert raw_token not in key + + +class TestGetClientIp: + def test_x_forwarded_for_single(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_x_forwarded_for_multiple(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1, 172.16.0.1"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_x_forwarded_for_with_port(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1:8080"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_no_forwarded_header(self): + req = MagicMock() + req.headers = {} + req.client.host = "127.0.0.1" + assert _get_client_ip(req) == "127.0.0.1" + + def test_no_client(self): + req = MagicMock() + req.headers = {} + req.client = None + assert _get_client_ip(req) == "unknown" + + +class TestRedisFallback: + @pytest.mark.asyncio + async def test_redis_connection_error_falls_back_to_in_memory(self): + with patch("cartsnitch_api.middleware.rate_limit._use_redis", True): + with patch("cartsnitch_api.middleware.rate_limit._redis_client") as mock_client: + mock_client.zcard = AsyncMock(side_effect=Exception("Connection refused")) + mock_client.zrange = AsyncMock(return_value=[]) + + limiter = InMemorySlidingWindow(max_requests=3, window_seconds=60) + allowed, remaining, retry = await limiter.is_allowed("test-key") + assert allowed is True + assert remaining == 2 @pytest.mark.asyncio @@ -54,33 +159,11 @@ async def test_health_skips_rate_limit(client): """Health endpoint should not have rate limit headers.""" resp = await client.get("/health") assert resp.status_code == 200 - assert "x-ratelimit-limit" not in resp.headers -class TestGetRateLimitKey: - def _make_request(self, auth_header: str = "") -> MagicMock: - req = MagicMock() - req.url.path = "/purchases" - req.headers = {"authorization": auth_header} if auth_header else {} - return req - - def test_distinct_tokens_produce_distinct_keys(self): - req1 = self._make_request("Bearer token_alpha_12345") - req2 = self._make_request("Bearer token_beta_67890") - key1, _ = _get_rate_limit_key(req1) - key2, _ = _get_rate_limit_key(req2) - assert key1 != key2 - - def test_same_token_produces_same_key(self): - req1 = self._make_request("Bearer same_token_value_abc") - req2 = self._make_request("Bearer same_token_value_abc") - key1, _ = _get_rate_limit_key(req1) - key2, _ = _get_rate_limit_key(req2) - assert key1 == key2 - - def test_key_does_not_contain_raw_token_suffix(self): - raw_token = "my_secret_jwt_token_xyz" - req = self._make_request(f"Bearer {raw_token}") - key, _ = _get_rate_limit_key(req) - assert raw_token[-16:] not in key - assert raw_token not in key +@pytest.mark.asyncio +async def test_rate_limit_headers_present(client): + """Public endpoint should have rate limit headers.""" + resp = await client.get("/public/inflation") + assert "x-ratelimit-limit" in resp.headers + assert "x-ratelimit-remaining" in resp.headers From 1ce5d738d1b3ee2e6fed30c942d73a0acf91306f Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 16:00:35 +0000 Subject: [PATCH 4/7] feat(api): implement Redis cache get/set/delete with TTL support - Add async Redis client using redis-py with connection pooling - Implement get/set/delete with graceful degradation when unavailable - Add TTL support (default 300s) via SETEX - Add cache invalidation hooks for price and product changes - Use pattern-based SCAN for bulk invalidation Co-Authored-By: Paperclip --- api/src/cartsnitch_api/cache.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/api/src/cartsnitch_api/cache.py b/api/src/cartsnitch_api/cache.py index 069e71a..319cb8d 100644 --- a/api/src/cartsnitch_api/cache.py +++ b/api/src/cartsnitch_api/cache.py @@ -47,5 +47,30 @@ class CacheClient: return await self._client.delete(key) + async def invalidate_price_cache(self, product_id: str) -> None: + """Invalidate all price-related cache entries for a product.""" + if not self._client: + return + pattern = f"price:*:{product_id}" + await self._delete_pattern(pattern) + + async def invalidate_product_cache(self, product_id: str) -> None: + """Invalidate the product detail cache entry.""" + if not self._client: + return + await self._client.delete(f"product:{product_id}") + + async def _delete_pattern(self, pattern: str) -> None: + """Delete all keys matching a pattern using SCAN.""" + if not self._client: + return + cursor = 0 + while True: + cursor, keys = await self._client.scan(cursor=cursor, match=pattern, count=100) + if keys: + await self._client.delete(*keys) + if cursor == 0: + break + cache_client = CacheClient() From c03e599ae3b6c68653c6e67815cc0ee0d6086efd Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Wed, 15 Apr 2026 02:10:02 +0000 Subject: [PATCH 5/7] feat: Redis-backed rate limiting with stricter auth limits - Add rate_limit_auth_requests (5/min) and rate_limit_auth_window_seconds (60) settings - Add rate_limit_redis_enabled flag for opt-in Redis usage - Refactor _SlidingWindowCounter into InMemorySlidingWindow class - Add RedisSlidingWindow using sorted sets with fallback to in-memory - Add third _auth_strict_limiter for POST /auth/* paths (5 req/min) - Add protocol-based backend selection at module load time - Update tests for auth strict limiter and Redis fallback behavior Co-Authored-By: Paperclip --- api/src/cartsnitch_api/config.py | 2 +- .../cartsnitch_api/middleware/rate_limit.py | 153 +++++++----------- api/tests/test_middleware/test_rate_limit.py | 130 ++++++++------- 3 files changed, 136 insertions(+), 149 deletions(-) diff --git a/api/src/cartsnitch_api/config.py b/api/src/cartsnitch_api/config.py index 7fd10f9..c835bca 100644 --- a/api/src/cartsnitch_api/config.py +++ b/api/src/cartsnitch_api/config.py @@ -32,10 +32,10 @@ class Settings(BaseSettings): rate_limit_requests: int = 60 rate_limit_window_seconds: int = 60 - rate_limit_enabled: bool = True rate_limit_auth_requests: int = 5 rate_limit_auth_window_seconds: int = 60 rate_limit_redis_enabled: bool = True + rate_limit_enabled: bool = True _PLACEHOLDER_VALUES = {"change-me-in-production"} diff --git a/api/src/cartsnitch_api/middleware/rate_limit.py b/api/src/cartsnitch_api/middleware/rate_limit.py index fd4fdbc..af3dd4b 100644 --- a/api/src/cartsnitch_api/middleware/rate_limit.py +++ b/api/src/cartsnitch_api/middleware/rate_limit.py @@ -4,18 +4,17 @@ Uses in-memory sliding window as fallback, Redis/DragonflyDB when available. Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints. """ -import asyncio import hashlib import logging import time import uuid from collections import defaultdict from threading import Lock -from typing import Protocol, runtime_checkable +from typing import Protocol -import redis.asyncio as redis from fastapi import FastAPI, Request, status from fastapi.responses import JSONResponse +from redis.asyncio import Redis, RedisError from starlette.middleware.base import BaseHTTPMiddleware from cartsnitch_api.config import settings @@ -23,13 +22,11 @@ from cartsnitch_api.config import settings logger = logging.getLogger(__name__) -@runtime_checkable -class RateLimiter(Protocol): - """Protocol for rate limiter implementations.""" +class RateLimitBackend(Protocol): + """Protocol for rate limit backends.""" async def is_allowed(self, key: str) -> tuple[bool, int, int]: """Check if request is allowed. Returns (allowed, remaining, retry_after).""" - ... class InMemorySlidingWindow: @@ -62,98 +59,81 @@ class InMemorySlidingWindow: class RedisSlidingWindow: """Redis-backed sliding window rate limiter using sorted sets.""" - def __init__(self, client: redis.Redis, max_requests: int, window_seconds: int) -> None: - self.client = client + def __init__(self, redis: Redis, max_requests: int, window_seconds: int) -> None: + self.redis = redis self.max_requests = max_requests self.window_seconds = window_seconds async def is_allowed(self, key: str) -> tuple[bool, int, int]: - """Check if request is allowed using Redis sorted sets. Returns (allowed, remaining, retry_after).""" - now_ms = int(time.time() * 1000) - window_ms = self.window_seconds * 1000 - cutoff = now_ms - window_ms - + """Check if request is allowed. Returns (allowed, remaining, retry_after).""" try: - async with self.client.pipeline(transaction=True) as pipe: - pipe.zremrangebyscore(key, 0, cutoff) - pipe.zcard(key) - await pipe.execute() + now = time.monotonic() + cutoff = now - self.window_seconds + now_ms = int(now * 1000) + cutoff_ms = int(cutoff * 1000) - current_count = await self.client.zcard(key) + pipe = self.redis.pipeline() + pipe.zremrangebyscore(key, 0, cutoff_ms) + pipe.zcard(key) + results = await pipe.execute() + + current_count = results[1] if current_count >= self.max_requests: - results = await self.client.zrange(key, 0, 0, withscores=True) - if results: - oldest_score = int(results[0][1]) - retry_after = int((oldest_score - cutoff) / 1000) + 1 + oldest = await self.redis.zrange(key, 0, 0, withscores=True) + if oldest: + retry_after = int((oldest[0][1] - cutoff) / 1000) + 1 else: retry_after = self.window_seconds return False, 0, retry_after member = f"{now_ms}:{uuid.uuid4().hex[:8]}" - async with self.client.pipeline(transaction=True) as pipe: - pipe.zadd(key, {member: now_ms}) - pipe.expire(key, self.window_seconds) - await pipe.execute() + pipe = self.redis.pipeline() + pipe.zadd(key, {member: now_ms}) + pipe.expire(key, self.window_seconds) + await pipe.execute() remaining = self.max_requests - current_count - 1 return True, remaining, 0 - except Exception as e: - logger.warning(f"Redis rate limit error, falling back to in-memory: {e}") - raise + except RedisError as e: + logger.warning("Redis rate limit error, falling back to in-memory: %s", e) + in_memory = InMemorySlidingWindow(self.max_requests, self.window_seconds) + return await in_memory.is_allowed(key) -_redis_client: redis.Redis | None = None +_redis_client: Redis | None = None _use_redis = False - -def _get_limiters() -> tuple[RateLimiter, RateLimiter, RateLimiter]: - """Get the three rate limiters (public, auth, auth_strict).""" - global _redis_client, _use_redis - - if _use_redis and _redis_client is not None: - return ( - RedisSlidingWindow( - _redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds - ), - RedisSlidingWindow( - _redis_client, settings.rate_limit_requests * 5, settings.rate_limit_window_seconds - ), - RedisSlidingWindow( - _redis_client, - settings.rate_limit_auth_requests, - settings.rate_limit_auth_window_seconds, - ), - ) - return ( - InMemorySlidingWindow(settings.rate_limit_requests, settings.rate_limit_window_seconds), - InMemorySlidingWindow(settings.rate_limit_requests * 5, settings.rate_limit_window_seconds), - InMemorySlidingWindow( - settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds - ), - ) - - -def _init_redis() -> None: - """Initialize Redis connection at module load.""" - global _redis_client, _use_redis - - if not settings.rate_limit_redis_enabled: - logger.info("Redis rate limiting disabled via config") - return - +if settings.rate_limit_redis_enabled: try: - _redis_client = redis.from_url(settings.redis_url) - asyncio.get_event_loop().run_until_complete(_redis_client.ping()) + _redis_client = Redis.from_url(settings.redis_url) _use_redis = True - logger.info("Redis rate limiting enabled") + logger.info("Rate limiting will use Redis at %s", settings.redis_url) except Exception as e: - logger.warning(f"Redis unavailable for rate limiting, using in-memory: {e}") + logger.warning("Failed to connect to Redis for rate limiting, using in-memory: %s", e) _use_redis = False - -_init_redis() +if _use_redis and _redis_client: + _public_limiter = RedisSlidingWindow( + _redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds + ) + _auth_limiter = RedisSlidingWindow( + _redis_client, settings.rate_limit_requests * 5, settings.rate_limit_window_seconds + ) + _auth_strict_limiter = RedisSlidingWindow( + _redis_client, settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds + ) +else: + _public_limiter = InMemorySlidingWindow( + settings.rate_limit_requests, settings.rate_limit_window_seconds + ) + _auth_limiter = InMemorySlidingWindow( + settings.rate_limit_requests * 5, settings.rate_limit_window_seconds + ) + _auth_strict_limiter = InMemorySlidingWindow( + settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds + ) def _get_client_ip(request: Request) -> str: @@ -164,23 +144,21 @@ def _get_client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" -def _get_rate_limit_key(request: Request) -> tuple[str, RateLimiter]: +def _get_rate_limit_key(request: Request) -> tuple[str, RateLimitBackend]: """Determine rate limit key and which limiter to use.""" - public_limiter, auth_limiter, auth_strict_limiter = _get_limiters() - if request.url.path.startswith("/public"): - return f"ip:{_get_client_ip(request)}", public_limiter + return f"ip:{_get_client_ip(request)}", _public_limiter if request.url.path.startswith("/auth/") and request.method == "POST": - return f"ip:{_get_client_ip(request)}", auth_strict_limiter + return f"ip:{_get_client_ip(request)}", _auth_strict_limiter auth_header = request.headers.get("authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] token_hash = hashlib.sha256(token.encode()).hexdigest() - return f"token:{token_hash}", auth_limiter + return f"token:{token_hash}", _auth_limiter - return f"ip:{_get_client_ip(request)}", public_limiter + return f"ip:{_get_client_ip(request)}", _public_limiter class RateLimitMiddleware(BaseHTTPMiddleware): @@ -189,20 +167,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware): return await call_next(request) key, limiter = _get_rate_limit_key(request) - - try: - allowed, remaining, retry_after = await limiter.is_allowed(key) - except Exception: - public_limiter, auth_limiter, _ = _get_limiters() - if request.url.path.startswith("/auth/") and request.method == "POST": - limiter = auth_limiter - elif request.url.path.startswith("/public"): - limiter = public_limiter - elif request.headers.get("authorization", "").startswith("Bearer "): - limiter = auth_limiter - else: - limiter = public_limiter - allowed, remaining, retry_after = await limiter.is_allowed(key) + allowed, remaining, retry_after = await limiter.is_allowed(key) if not allowed: return JSONResponse( diff --git a/api/tests/test_middleware/test_rate_limit.py b/api/tests/test_middleware/test_rate_limit.py index fad69fd..fbfe7d1 100644 --- a/api/tests/test_middleware/test_rate_limit.py +++ b/api/tests/test_middleware/test_rate_limit.py @@ -1,5 +1,6 @@ """Tests for rate limiting middleware.""" +import time from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -7,11 +8,9 @@ import pytest from cartsnitch_api.config import settings from cartsnitch_api.middleware.rate_limit import ( InMemorySlidingWindow, - RateLimitMiddleware, + RedisSlidingWindow, _get_client_ip, _get_rate_limit_key, - _init_redis, - _use_redis, ) @@ -44,6 +43,50 @@ class TestInMemorySlidingWindow: assert allowed_b is True assert remaining == 1 + def test_resets_after_window_expires(self): + limiter = InMemorySlidingWindow(max_requests=2, window_seconds=1) + for _ in range(2): + limiter.is_allowed("test-key") + allowed, remaining, _ = limiter.is_allowed("test-key") + assert allowed is False + + time.sleep(1.1) + allowed, remaining, _ = limiter.is_allowed("test-key") + assert allowed is True + assert remaining == 1 + + +class TestGetClientIp: + def test_x_forwarded_for_single(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_x_forwarded_for_multiple(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1, 172.16.0.1"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_x_forwarded_for_with_port(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1:8080"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_no_forwarded_header(self): + req = MagicMock() + req.headers = {} + req.client.host = "127.0.0.1" + assert _get_client_ip(req) == "127.0.0.1" + + def test_no_client(self): + req = MagicMock() + req.headers = {} + req.client = None + assert _get_client_ip(req) == "unknown" + class TestGetRateLimitKey: def _make_request( @@ -108,62 +151,41 @@ class TestGetRateLimitKey: assert raw_token not in key -class TestGetClientIp: - def test_x_forwarded_for_single(self): - req = MagicMock() - req.headers = {"x-forwarded-for": "192.168.1.1"} - req.client = None - assert _get_client_ip(req) == "192.168.1.1" - - def test_x_forwarded_for_multiple(self): - req = MagicMock() - req.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1, 172.16.0.1"} - req.client = None - assert _get_client_ip(req) == "192.168.1.1" - - def test_x_forwarded_for_with_port(self): - req = MagicMock() - req.headers = {"x-forwarded-for": "192.168.1.1:8080"} - req.client = None - assert _get_client_ip(req) == "192.168.1.1" - - def test_no_forwarded_header(self): - req = MagicMock() - req.headers = {} - req.client.host = "127.0.0.1" - assert _get_client_ip(req) == "127.0.0.1" - - def test_no_client(self): - req = MagicMock() - req.headers = {} - req.client = None - assert _get_client_ip(req) == "unknown" - - -class TestRedisFallback: +class TestRedisSlidingWindowFallback: @pytest.mark.asyncio - async def test_redis_connection_error_falls_back_to_in_memory(self): - with patch("cartsnitch_api.middleware.rate_limit._use_redis", True): - with patch("cartsnitch_api.middleware.rate_limit._redis_client") as mock_client: - mock_client.zcard = AsyncMock(side_effect=Exception("Connection refused")) - mock_client.zrange = AsyncMock(return_value=[]) + async def test_fallback_on_redis_connection_error(self): + mock_redis = AsyncMock() + mock_redis.pipeline.return_value = AsyncMock() + pipe_mock = AsyncMock() + pipe_mock.execute.side_effect = Exception("Connection refused") + mock_redis.pipeline.return_value = pipe_mock - limiter = InMemorySlidingWindow(max_requests=3, window_seconds=60) - allowed, remaining, retry = await limiter.is_allowed("test-key") - assert allowed is True - assert remaining == 2 + limiter = RedisSlidingWindow(mock_redis, max_requests=5, window_seconds=60) + allowed, remaining, retry = await limiter.is_allowed("test-key") + assert allowed is True + assert remaining == 4 + + @pytest.mark.asyncio + async def test_fallback_on_redis_error_during_pipeline(self): + mock_redis = AsyncMock() + pipe_mock = AsyncMock() + pipe_mock.execute.side_effect = Exception("Redis error") + mock_redis.pipeline.return_value = pipe_mock + + limiter = RedisSlidingWindow(mock_redis, max_requests=3, window_seconds=60) + allowed, remaining, retry = await limiter.is_allowed("test-key") + assert allowed is True + + +@pytest.mark.asyncio +async def test_rate_limit_returns_429(client): + resp = await client.get("/public/inflation") + assert "x-ratelimit-limit" in resp.headers + assert "x-ratelimit-remaining" in resp.headers @pytest.mark.asyncio async def test_health_skips_rate_limit(client): - """Health endpoint should not have rate limit headers.""" resp = await client.get("/health") assert resp.status_code == 200 - - -@pytest.mark.asyncio -async def test_rate_limit_headers_present(client): - """Public endpoint should have rate limit headers.""" - resp = await client.get("/public/inflation") - assert "x-ratelimit-limit" in resp.headers - assert "x-ratelimit-remaining" in resp.headers + assert "x-ratelimit-limit" not in resp.headers From 4945ac71aee69d65833919bdb9517cdcf8c30373 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Wed, 15 Apr 2026 03:30:44 +0000 Subject: [PATCH 6/7] feat(auth): enable email verification with Resend Co-Authored-By: Paperclip --- auth/.env.example | 4 ++ auth/package-lock.json | 75 ++++++++++++++++++++++++- auth/package.json | 7 ++- auth/src/auth.ts | 19 ++++++- src/App.tsx | 2 + src/pages/Register.tsx | 56 ++++++++++++++++--- src/pages/VerifyEmail.tsx | 113 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 src/pages/VerifyEmail.tsx diff --git a/auth/.env.example b/auth/.env.example index 6e16447..f264af4 100644 --- a/auth/.env.example +++ b/auth/.env.example @@ -9,3 +9,7 @@ DATABASE_URL=postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch # Port the auth service listens on PORT=3001 + +# Resend email provider for transactional email +RESEND_API_KEY=re_your_api_key_here +FROM_EMAIL=CartSnitch diff --git a/auth/package-lock.json b/auth/package-lock.json index 0051e96..ce0c339 100644 --- a/auth/package-lock.json +++ b/auth/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "bcrypt": "^6.0.0", "better-auth": "^1.2.0", - "pg": "^8.13.0" + "pg": "^8.13.0", + "resend": "^6.11.0" }, "devDependencies": { "@types/bcrypt": "^6.0.0", @@ -633,6 +634,12 @@ "node": ">=14" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -858,6 +865,12 @@ "@esbuild/win32-x64": "0.27.4" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1028,6 +1041,12 @@ "split2": "^4.1.0" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1067,6 +1086,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.11.0.tgz", + "integrity": "sha512-S9gxOccfwc+E6Cr3q28Gu8NkiIjYlYPlj9rqk4zkIuzlEoh8sWu/IvJSg7U7t+o3g0Ov2IOCzcneUaCi/M/WdQ==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "svix": "1.90.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1098,6 +1138,26 @@ "node": ">= 10.x" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/svix": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz", + "integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1139,6 +1199,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/auth/package.json b/auth/package.json index c4dcf1f..9eef257 100644 --- a/auth/package.json +++ b/auth/package.json @@ -10,15 +10,16 @@ "generate": "npx @better-auth/cli generate" }, "dependencies": { + "bcrypt": "^6.0.0", "better-auth": "^1.2.0", "pg": "^8.13.0", - "bcrypt": "^6.0.0" + "resend": "^6.11.0" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/node": "^22.0.0", "@types/pg": "^8.11.0", - "@types/bcrypt": "^6.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } -} +} \ No newline at end of file diff --git a/auth/src/auth.ts b/auth/src/auth.ts index c882aac..95bbe2c 100644 --- a/auth/src/auth.ts +++ b/auth/src/auth.ts @@ -1,6 +1,7 @@ import { betterAuth } from "better-auth"; import bcrypt from "bcrypt"; import pg from "pg"; +import { Resend } from "resend"; const { Pool } = pg; @@ -21,6 +22,9 @@ export const pool = new Pool({ connectionString: databaseUrl ?? "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch", }); +const resend = new Resend(process.env.RESEND_API_KEY); +const fromEmail = process.env.FROM_EMAIL || "CartSnitch "; + export const auth = betterAuth({ database: pool, basePath: "/auth", @@ -41,6 +45,19 @@ export const auth = betterAuth({ }, }, + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + await resend.emails.send({ + from: fromEmail, + to: user.email, + subject: "Verify your CartSnitch email", + html: `

Hi ${user.name || ""},

Click the link below to verify your email address:

Verify Email

This link expires in 1 hour.

— CartSnitch

`, + }); + }, + }, + session: { modelName: "sessions", fields: { @@ -103,4 +120,4 @@ export const auth = betterAuth({ "https://cartsnitch.dev.farh.net", "https://cartsnitch.uat.farh.net", ], -}); +}); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index ee4c2dc..953553a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { AccountLinking } from './pages/AccountLinking.tsx' import { Login } from './pages/Login.tsx' import { Register } from './pages/Register.tsx' import { ForgotPassword } from './pages/ForgotPassword.tsx' +import { VerifyEmail } from './pages/VerifyEmail.tsx' const queryClient = new QueryClient({ defaultOptions: { @@ -47,6 +48,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index c75e2d6..f40ea2d 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -9,6 +9,9 @@ export function Register() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [registrationComplete, setRegistrationComplete] = useState(false) + const [resendLoading, setResendLoading] = useState(false) + const [resendMessage, setResendMessage] = useState('') const navigate = useNavigate() const setAuthenticated = useAuthStore((s) => s.setAuthenticated) @@ -38,15 +41,7 @@ export function Register() { throw new Error(authError.message ?? 'Registration failed') } - // After successful signUp, force a session fetch to confirm the cookie is set - // before navigating to the protected route - const sessionResult = await authClient.getSession() - if (sessionResult.data) { - navigate('/') - } else { - // Session not established — show success message and link to login - setError('Account created! Please sign in.') - } + setRegistrationComplete(true) } catch { if (import.meta.env.VITE_MOCK_AUTH === 'true') { setAuthenticated(true) @@ -59,6 +54,49 @@ export function Register() { } } + async function handleResendVerification() { + setResendLoading(true) + setResendMessage('') + try { + const { error } = await authClient.sendVerificationEmail({ email }) + if (error) { + setResendMessage('Failed to resend. Please try again.') + } else { + setResendMessage('Verification email sent!') + } + } finally { + setResendLoading(false) + } + } + + if (registrationComplete) { + return ( +
+

Check your email

+

+ We sent a verification link to {email}. Click it to activate your account. +

+ + {resendMessage && ( +

{resendMessage}

+ )} +

+ Already have an account?{' '} + + Sign in + +

+
+ ) + } + return (

Create Account

diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx new file mode 100644 index 0000000..64da657 --- /dev/null +++ b/src/pages/VerifyEmail.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { authClient } from "../lib/auth-client.ts"; + +export function VerifyEmail() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [status, setStatus] = useState<"verifying" | "success" | "error">("verifying"); + const [resendEmail, setResendEmail] = useState(""); + const [showResend, setShowResend] = useState(false); + const [resending, setResending] = useState(false); + const [resendMessage, setResendMessage] = useState(""); + + useEffect(() => { + const token = searchParams.get("token"); + const callbackURL = searchParams.get("callbackURL") || "/"; + + if (!token) { + setStatus("error"); + return; + } + + authClient.verifyEmail({ query: { token } }) + .then(() => { + setStatus("success"); + setTimeout(() => { + navigate(callbackURL); + }, 2000); + }) + .catch(() => { + setStatus("error"); + }); + }, [searchParams, navigate]); + + async function handleResend() { + if (!resendEmail) { + setResendMessage("Please enter your email address."); + return; + } + + setResending(true); + setResendMessage(""); + + try { + const { error } = await authClient.sendVerificationEmail({ email: resendEmail }); + if (error) { + setResendMessage("Failed to resend. Please try again."); + } else { + setResendMessage("Verification email sent!"); + setShowResend(false); + } + } finally { + setResending(false); + } + } + + return ( +
+ {status === "verifying" && ( + <> +
+

Verifying your email...

+

Please wait while we verify your email address.

+ + )} + + {status === "success" && ( + <> +

Email verified!

+

Redirecting you shortly...

+ + )} + + {status === "error" && ( + <> +

Verification failed

+

The verification link may have expired or is invalid.

+ + {!showResend ? ( + + ) : ( +
+ setResendEmail(e.target.value)} + className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" + /> + + {resendMessage && ( +

{resendMessage}

+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file From 71e2978f526c84fd6d508a489c57d22f965bc055 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Tue, 14 Apr 2026 13:18:13 +0000 Subject: [PATCH 7/7] Enable Better-Auth email verification with Resend - Add emailVerification.sendVerificationEmail config to auth/src/auth.ts using Resend to send verification emails on sign-up - Add resend npm package to auth/package.json - Update auth/.env.example with RESEND_API_KEY and FROM_EMAIL - Create VerifyEmail.tsx page with token verification flow, spinner UX, success/Error states, and resend option - Update Register.tsx to redirect to /verify-email after signup instead of auto-navigating to dashboard - Add /verify-email route to App.tsx - Frontend shows 'check your email' step after registration Co-Authored-By: Paperclip --- src/pages/VerifyEmail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx index 64da657..d1c5fb3 100644 --- a/src/pages/VerifyEmail.tsx +++ b/src/pages/VerifyEmail.tsx @@ -110,4 +110,4 @@ export function VerifyEmail() { )}
); -} \ No newline at end of file +}