diff --git a/CHANGELOG.md b/CHANGELOG.md index 514e58c..ba0a24d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to AI-RPG are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.4] — 2026-06-20 + +### Fixed (proper fix for CORS env parsing) +- **app/config.py**: replaced the broken `Annotated[list[str], NoDecode]` approach with a simpler, more robust one: + - `cors_origins` is now declared as a plain `str` (comma-separated, e.g. `"http://a,http://b"` or `"*"`). + - Added a `cors_origins_list` property that splits the string into a `list[str]` on demand. + - This sidesteps the entire `EnvSettingsSource.decode_complex_value` / JSON-parsing codepath — pydantic-settings sees a `str` field, takes the env value as-is, no JSON parsing attempted. +- **app/main.py**: updated `CORSMiddleware(allow_origins=cfg.cors_origins_list)` to use the new property. +- **Why v1.0.3 didn't work**: in pydantic-settings 2.7.0, `NoDecode` is importable but `_annotation_is_complex()` doesn't actually check for it (only checks for `Json`). The marker was added to the package surface but the inner logic was only wired up in a later version. Switching to a plain `str` field is the most reliable fix and works across all pydantic-settings 2.x versions. +- All 68 unit tests still pass. + ## [1.0.3] — 2026-06-20 ### Fixed diff --git a/app/config.py b/app/config.py index 4f40c00..8130592 100644 --- a/app/config.py +++ b/app/config.py @@ -13,10 +13,9 @@ from __future__ import annotations from functools import lru_cache from pathlib import Path -from typing import Annotated -from pydantic import Field, field_validator -from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): @@ -38,10 +37,11 @@ class Settings(BaseSettings): jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 24 # 24 hours refresh_token_expire_minutes: int = 60 * 24 * 7 # 7 days - # `NoDecode` tells pydantic-settings to NOT JSON-parse the env var value - # before passing it to our `_split_cors` validator. Without it, the value - # `CORS_ORIGINS=http://a,http://b` would be rejected as invalid JSON. - cors_origins: Annotated[list[str], NoDecode] = Field(default_factory=lambda: ["*"]) + # Comma-separated list of allowed CORS origins, e.g. + # CORS_ORIGINS=http://localhost:8080,http://localhost:5173 + # Special value "*" allows all origins. + # Use `settings.cors_origins_list` to access the parsed list. + cors_origins: str = "*" # === Admin setup === admin_setup_token: str = "" # if empty, will be auto-generated and stored in DB @@ -109,12 +109,16 @@ class Settings(BaseSettings): if not self.assets_dir: self.assets_dir = str(Path(self.data_dir) / "assets") - @field_validator("cors_origins", mode="before") - @classmethod - def _split_cors(cls, v): - if isinstance(v, str): - return [item.strip() for item in v.split(",") if item.strip()] - return v + @property + def cors_origins_list(self) -> list[str]: + """Parse `cors_origins` (comma-separated string) into a list. + + Whitespace is stripped from each entry, empty entries are dropped. + Returns `["*"]` if the field is empty or just whitespace. + """ + if not self.cors_origins or not self.cors_origins.strip(): + return ["*"] + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] @lru_cache(maxsize=1) diff --git a/app/main.py b/app/main.py index f0c0c4d..629d606 100644 --- a/app/main.py +++ b/app/main.py @@ -98,7 +98,7 @@ def create_app() -> FastAPI: # CORS app.add_middleware( CORSMiddleware, - allow_origins=cfg.cors_origins, + allow_origins=cfg.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"],