Skip to content
Merged
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
30 changes: 28 additions & 2 deletions api/core/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json

from pydantic_settings import BaseSettings, SettingsConfigDict


Expand All @@ -10,8 +12,12 @@ class Settings(BaseSettings):

PROJECT_NAME: str = "Workspaces API"

# Comma separated list of origins, or "*" to allow all origins. Defaults to an empty list.
# Example: CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com"
# Allowed CORS origins. Accepts either a comma-separated list OR a JSON
# array (the deployment stack historically supplies a JSON array), plus "*"
# for all origins. Read via `cors_origins_list`, not this raw string.
# Examples:
# CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com"
# CORS_ORIGINS='["https://workspaces.example.com"]'
CORS_ORIGINS: str = ""

TASK_DATABASE_URL: str = (
Expand Down Expand Up @@ -55,6 +61,26 @@ class Settings(BaseSettings):

SENTRY_DSN: str = ""

@property
def cors_origins_list(self) -> list[str]:
"""Allowed CORS origins as a list.

Tolerates both formats the deployment has used: a JSON array
(``["https://a","https://b"]``) and a comma-separated string
(``https://a,https://b``). ``"*"`` is passed through as-is.
"""
raw = self.CORS_ORIGINS.strip()
if not raw:
return []
if raw.startswith("["):
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, list):
return [str(o).strip() for o in parsed if str(o).strip()]
return [o.strip() for o in raw.split(",") if o.strip()]

model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
Expand Down
4 changes: 1 addition & 3 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,7 @@ async def lifespan(_app: FastAPI):

app.add_middleware(
CORSMiddleware,
allow_origins=[
origin.strip() for origin in settings.CORS_ORIGINS.split(",") if origin.strip()
],
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,43 @@ def test_cors_origins_parsed_from_json_env(monkeypatch):
assert s.CORS_ORIGINS == "https://a.example,https://b.example"


def test_cors_origins_list_comma_separated(monkeypatch):
monkeypatch.setenv("CORS_ORIGINS", "https://a.example, https://b.example")
s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg
assert s.cors_origins_list == ["https://a.example", "https://b.example"]


def test_cors_origins_list_json_array(monkeypatch):
# The deployment stack supplies a JSON array; it must parse, not become one
# malformed bracketed origin.
monkeypatch.setenv("CORS_ORIGINS", '["https://a.example","https://b.example"]')
s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg
assert s.cors_origins_list == ["https://a.example", "https://b.example"]


def test_cors_origins_list_single_json_array(monkeypatch):
monkeypatch.setenv("CORS_ORIGINS", '["https://only.example"]')
s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg
assert s.cors_origins_list == ["https://only.example"]


def test_cors_origins_list_empty_and_wildcard(monkeypatch):
s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg
assert s.cors_origins_list == []

monkeypatch.setenv("CORS_ORIGINS", "*")
s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg
assert s.cors_origins_list == ["*"]


def test_cors_origins_list_malformed_json_falls_back_to_comma(monkeypatch):
# A value that starts with "[" but isn't valid JSON should not crash; fall
# back to comma-splitting rather than raising.
monkeypatch.setenv("CORS_ORIGINS", "[not json")
s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg
assert s.cors_origins_list == ["[not json"]


def test_value_types_and_formats():
s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg

Expand Down
Loading