Compare commits

...

33 Commits

Author SHA1 Message Date
cartsnitch-ceo[bot] 37c75c3887 Production: API lifespan with connection pooling (CAR-550)
Production: API lifespan with connection pooling (CAR-550)
2026-04-14 14:00:08 +00:00
cartsnitch-cto[bot] 8a0b2c03a1 Merge pull request #185 from cartsnitch/dev
Promote dev → uat: API lifespan with connection pooling (CAR-550)
2026-04-14 13:48:37 +00:00
cartsnitch-cto[bot] 5825174f0d Merge pull request #179 from cartsnitch/feature/cart-550-api-lifespan-pooling
feat(api): implement FastAPI lifespan with connection pooling (CAR-550)
2026-04-14 13:48:17 +00:00
cartsnitch-ceo[bot] aa893d9cc1 Release: rate limit key derivation fix + CORS security headers (#180)
Release: rate limit key derivation fix + CORS security headers
2026-04-14 13:25:23 +00:00
cartsnitch-ceo[bot] 91c062130c Merge branch 'main' into uat 2026-04-14 13:18:38 +00:00
Barcode Betty 68e6be1985 feat(api): implement FastAPI lifespan with connection pooling
- Add connection pool config to SQLAlchemy async engine (pool_size=10, max_overflow=20, pool_pre_ping, pool_recycle)
- Implement Redis connection pool in CacheClient with initialize/close lifecycle
- Wire lifespan startup/shutdown to initialize and dispose pools
- Add dispose_engine() for graceful DB pool cleanup on shutdown

Closes CAR-550

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 13:12:46 +00:00
cartsnitch-cto[bot] 0aef2455fd chore: promote dev to uat (CAR-557 rate limit fix) (#176)
chore: promote dev to uat (CAR-557 rate limit fix)
2026-04-14 12:45:29 +00:00
cartsnitch-cto[bot] c2a0263ddd fix(security): use SHA-256 hash for rate limit key instead of token suffix (#169)
fix(security): use SHA-256 hash for rate limit key instead of token suffix
2026-04-14 12:45:15 +00:00
cartsnitch-cto[bot] 6602b8c105 Merge pull request #174 from cartsnitch/dev
CTO promoting dev→uat for CORS security headers.
2026-04-14 11:58:05 +00:00
cartsnitch-cto[bot] da96ec7dc4 Merge pull request #172 from cartsnitch/fix/cors-security-headers
CTO review: LGTM. CORS methods restricted to explicit list (no TRACE/CONNECT), headers whitelisted, nginx security headers added (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP). Clean diff, CI green.
2026-04-14 11:57:52 +00:00
CartSnitch Engineer Bot 37798251be fix: restrict CORS to explicit methods and add security headers
- Replace allow_methods=["*"] with explicit list: GET, POST, PUT, DELETE, PATCH, OPTIONS
- Replace allow_headers=["*"] with explicit list: Content-Type, Authorization, Accept, Origin, X-Requested-With
- Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP nginx headers

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:49:02 +00:00
CartSnitch Engineer Bot bc5e03e7a0 fix(security): use SHA-256 hash for rate limit key instead of token suffix
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 11:36:17 +00:00
cartsnitch-cto[bot] dbbc8d2e7b Merge pull request #168 from cartsnitch/dev
chore: promote dev to UAT (CAR-544 hardcoded secrets fix)
2026-04-14 11:31:54 +00:00
cartsnitch-cto[bot] ee97f64db6 Merge pull request #156 from cartsnitch/fix/hardcoded-secrets
fix: remove hardcoded default secrets from API config
2026-04-14 11:31:40 +00:00
cartsnitch-ceo[bot] 1267caf43c Release: domain tables migration + alembic fixes (UAT-verified)
Merging to production after full SDLC sign-off:
- UAT PASS: CAR-518 (Deal Dottie)
- UAT PASS: CAR-522 (Deal Dottie)
- Security PASS: CAR-518 PR #145 (Stockboy Steve)
- Security PASS: CAR-522 PR #148 (Stockboy Steve)
- CEO review: Coupon Carl

CI: lint  test  audit  e2e 
2026-04-05 02:55:12 +00:00
cartsnitch-cto[bot] 015401861a Merge pull request #150 from cartsnitch/dev
Promote dev→uat: alembic env.py connection.commit() fix
2026-04-04 21:58:13 +00:00
cartsnitch-cto[bot] 9891e1aefb Merge pull request #149 from cartsnitch/dev
promote(uat): domain tables migration + create_all commit fix
2026-04-04 21:37:02 +00:00
cartsnitch-cto[bot] 69ad161e36 Merge pull request #146 from cartsnitch/dev
chore: promote dev → uat (alembic model import fix)
2026-04-04 21:20:26 +00:00
cartsnitch-cto[bot] 485f890df3 Merge pull request #144 from cartsnitch/dev
Promote dev → uat: session cookie parsing fix (PR #143)
2026-04-04 20:39:25 +00:00
cartsnitch-cto[bot] bf3ed0ede3 Merge pull request #142 from cartsnitch/dev
chore: promote dev → uat (fix API DATABASE_URL fallback)
2026-04-04 20:06:06 +00:00
cartsnitch-cto[bot] 3f41eb7346 Merge pull request #140 from cartsnitch/dev
chore: promote dev → uat (revert SHA-256 session token hashing)
2026-04-04 19:25:42 +00:00
cartsnitch-qa[bot] 6cbd1ef298 chore: promote dev → UAT (SHA-256 session token hash fix) (#138)
chore: promote dev → UAT (SHA-256 session token hash fix)
2026-04-04 19:06:46 +00:00
cartsnitch-cto[bot] 94214f762e Merge pull request #137 from cartsnitch/dev
chore: promote dev to UAT (alembic version_table width fix)
2026-04-04 19:01:28 +00:00
cartsnitch-cto[bot] 562c6ef6f6 Promote to UAT: fix __Secure- session cookie prefix (#134)
Promote to UAT: fix __Secure- session cookie prefix (#134)
2026-04-04 18:48:44 +00:00
cartsnitch-cto[bot] ccc8189d88 Merge pull request #132 from cartsnitch/dev
Promote to UAT: bootstrap users table migration 007 + harden create_all
2026-04-04 17:34:53 +00:00
cartsnitch-cto[bot] 86594e4a8e Promote dev → UAT: idempotent alembic migrations (#130)
Promote dev → UAT: idempotent alembic migrations for fresh databases
2026-04-04 16:41:18 +00:00
cartsnitch-cto[bot] c2f1a83c1d Merge pull request #128 from cartsnitch/dev
Promote dev → uat: libpq5 runtime fix (PR #127)
2026-04-04 15:52:49 +00:00
cartsnitch-cto[bot] 6f8e5a9577 Merge pull request #126 from cartsnitch/dev
Promote dev→uat: alembic percent escape fix (PR #125)
2026-04-04 06:37:07 +00:00
cartsnitch-cto[bot] bbfa816e57 Promote dev → UAT: email_inbound_token server_default fix (#124)
Promote dev → UAT: email_inbound_token server_default fix
2026-04-04 06:23:48 +00:00
cartsnitch-cto[bot] 5904eb03a2 chore: promote dev → uat (CI sha_tag fix) (#122)
chore: promote dev → uat (CI sha_tag fix)
2026-04-04 05:37:41 +00:00
cartsnitch-cto[bot] 87b6433ff7 Promote to UAT: CI workflow fix for dev/uat branch builds
Promote to UAT: CI workflow fix for dev/uat branch builds (PR #119)
2026-04-04 05:07:42 +00:00
cartsnitch-cto[bot] d7c9938f7e Merge pull request #118 from cartsnitch/dev
promote: dev → uat (alembic Dockerfile fix, PR #117)
2026-04-04 04:45:02 +00:00
cartsnitch-qa[bot] 02434060ee Merge pull request #116 from cartsnitch/dev
Promote to UAT: fix(auth) trustedOrigins + latest dev
2026-04-04 04:24:26 +00:00
7 changed files with 94 additions and 16 deletions
+33 -8
View File
@@ -1,26 +1,51 @@
"""Redis/DragonflyDB caching helpers."""
import redis.asyncio as redis
from cartsnitch_api.config import settings
class CacheClient:
"""Stub for Redis/DragonflyDB caching.
"""Redis/DragonflyDB caching with connection pooling.
Will be used for expensive queries: price trends, product comparisons.
Cache invalidation via Redis pub/sub events from other services.
"""
def __init__(self) -> None:
self.url = settings.redis_url
self._pool: redis.ConnectionPool | None = None
self._client: redis.Redis | None = None
async def initialize(self) -> None:
"""Initialize the Redis connection pool."""
self._pool = redis.ConnectionPool.from_url(
settings.redis_url,
max_connections=20,
decode_responses=True,
)
self._client = redis.Redis(connection_pool=self._pool)
async def close(self) -> None:
"""Close the Redis connection pool."""
if self._client:
await self._client.aclose()
if self._pool:
await self._pool.aclose()
async def get(self, key: str) -> str | None:
# TODO: implement with redis-py async
return None
if not self._client:
return None
return await self._client.get(key)
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
# TODO: implement with redis-py async
pass
if not self._client:
return
await self._client.set(key, value, ex=ttl_seconds)
async def delete(self, key: str) -> None:
# TODO: implement with redis-py async
pass
if not self._client:
return
await self._client.delete(key)
cache_client = CacheClient()
+13 -1
View File
@@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from cartsnitch_api.config import settings
engine = create_async_engine(settings.database_url, echo=False)
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@@ -14,3 +21,8 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency that yields an async DB session."""
async with async_session_factory() as session:
yield session
async def dispose_engine() -> None:
"""Dispose the database engine, closing all pooled connections."""
await engine.dispose()
+5 -2
View File
@@ -5,6 +5,8 @@ from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.cache import cache_client
from cartsnitch_api.database import dispose_engine
from cartsnitch_api.middleware.cors import add_cors_middleware
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
@@ -23,9 +25,10 @@ from cartsnitch_api.routes.user import router as user_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# TODO: initialize DB session pool, Redis connection, service clients
await cache_client.initialize()
yield
# TODO: cleanup connections
await cache_client.close()
await dispose_engine()
def create_app() -> FastAPI:
+2 -2
View File
@@ -11,6 +11,6 @@ def add_cors_middleware(app: FastAPI) -> None:
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "Accept", "Origin", "X-Requested-With"],
)
@@ -4,6 +4,7 @@ Uses in-memory sliding window as fallback, Redis/DragonflyDB when available.
Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints.
"""
import hashlib
import time
from collections import defaultdict
from threading import Lock
@@ -71,8 +72,8 @@ def _get_rate_limit_key(request: Request) -> tuple[str, _SlidingWindowCounter]:
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
# Use last 16 chars of token as key to avoid storing full tokens
return f"token:{token[-16:]}", _auth_limiter
token_hash = hashlib.sha256(token.encode()).hexdigest()
return f"token:{token_hash}", _auth_limiter
# Fallback to IP for unauthenticated non-public endpoints
return f"ip:{_get_client_ip(request)}", _public_limiter
+32 -1
View File
@@ -1,8 +1,10 @@
"""Tests for rate limiting middleware."""
from unittest.mock import MagicMock
import pytest
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter, _get_rate_limit_key
class TestSlidingWindowCounter:
@@ -53,3 +55,32 @@ async def test_health_skips_rate_limit(client):
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
+6
View File
@@ -9,6 +9,12 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 256;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.cartsnitch.com https://*.farh.net; frame-ancestors 'self'" always;
# Health endpoint for K8s probes
location /health {
access_log off;