Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,8 @@ JWT_SECRET=change-this-in-production-min-32-bytes
JWT_ALGORITHM=HS256
ACCESS_TOKEN_MINUTES=720

# ── CORS ───────────────────────────────────────────────────────
CORS_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://127.0.0.1:3000

# ── Database ───────────────────────────────────────────────────
DATABASE_URL=sqlite:///./assistant.db
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ Important variables:
- `JWT_SECRET`
- `DATABASE_URL`
- `RATE_LIMIT_PER_MINUTE`
- `CORS_ALLOW_ORIGINS`
- `LLM_API_KEY` (optional)

The app can still run without external AI providers when `LLM_ENABLED=false`.
Expand Down Expand Up @@ -372,6 +373,7 @@ The backend includes built-in resilience for LLM requests:
| Variable | Default | Description |
|---|---|---|
| `RATE_LIMIT_PER_MINUTE` | `30` | Max requests per IP per minute |
| `CORS_ALLOW_ORIGINS` | Local dev + Render app origins | Comma-separated browser origins allowed to call the API |
| `LLM_ENABLED` | `false` | Enable LLM provider |
| `LLM_API_KEY` | — | API key for your LLM provider |
| `LLM_BASE_URL` | `https://api.openai.com/v1` | LLM base URL |
Expand Down
19 changes: 19 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ def _bool_env(name: str, default: bool) -> bool:
return raw_value.strip().lower() in {"1", "true", "yes", "on"}


def _csv_env(name: str, default: list[str]) -> list[str]:
raw_value = os.getenv(name)
if raw_value is None:
return default

values = [value.strip() for value in raw_value.split(",") if value.strip()]
return values or default


class Settings:
"""Application settings loaded from environment variables."""

Expand All @@ -62,6 +71,16 @@ class Settings:
jwt_secret: str = os.getenv("JWT_SECRET", "change-this-in-production-min-32-bytes")
jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256")
access_token_minutes: int = _int_env("ACCESS_TOKEN_MINUTES", 720)
cors_allow_origins: list[str] = _csv_env(
"CORS_ALLOW_ORIGINS",
[
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:3000",
"http://127.0.0.1:3000",
"https://qyverixai.onrender.com",
],
)
llm_enabled: bool = _bool_env("LLM_ENABLED", False)
llm_api_key: str | None = os.getenv("LLM_API_KEY")
llm_base_url: str = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1")
Expand Down
13 changes: 11 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from .services import database
from .services.scheduler import start_scheduler, stop_scheduler
from .database import Base, engine
from .config import settings

from .schemas import HealthResponse

Expand Down Expand Up @@ -85,10 +86,18 @@ async def lifespan(app: FastAPI):

# ── Middleware ────────────────────────────────────────────────────────────────
app.add_middleware(GZipMiddleware, minimum_size=1000)


def _allow_credentials_for_origins(origins: list[str]) -> bool:
return "*" not in origins


_cors_allow_origins = settings.cors_allow_origins
_cors_allow_credentials = _allow_credentials_for_origins(_cors_allow_origins)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=_cors_allow_origins,
allow_credentials=_cors_allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)
Expand Down
34 changes: 34 additions & 0 deletions backend/tests/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,40 @@ def test_rate_limit_returns_429_with_retry_after_header():
assert r.headers["X-RateLimit-Remaining"] == "0"


def test_cors_allows_configured_origin_with_credentials():
r = client.options(
"/auth/me",
headers={
"Origin": "http://localhost:5173",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization",
},
)

assert r.status_code == 200
assert r.headers["access-control-allow-origin"] == "http://localhost:5173"
assert r.headers["access-control-allow-credentials"] == "true"


def test_cors_rejects_unconfigured_origin():
r = client.options(
"/auth/me",
headers={
"Origin": "https://attacker.example",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization",
},
)

assert r.status_code == 400
assert "access-control-allow-origin" not in r.headers


def test_wildcard_cors_disables_credentials():
assert app_main._allow_credentials_for_origins(["*"]) is False
assert app_main._allow_credentials_for_origins(["http://localhost:5173"]) is True


# ── Explanation ───────────────────────────────────────────────────────────────
def test_explanation_python():
r = client.post("/explanation/", json={"code": PYTHON_CLEAN, "language": "python"})
Expand Down
4 changes: 3 additions & 1 deletion render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ services:
value: 30
- key: LLM_ENABLED
value: false
- key: CORS_ALLOW_ORIGINS
value: https://qyverixai.onrender.com
staticPublishPath: frontend
routes:
- type: rewrite
source: /app
destination: /frontend/index.html
destination: /frontend/index.html