File size: 4,658 Bytes
a32aee9
 
 
f1fa34c
 
 
 
 
 
 
a32aee9
 
f1fa34c
 
 
a32aee9
f1fa34c
a32aee9
 
 
f1fa34c
 
 
 
 
 
a32aee9
 
 
 
f1fa34c
 
 
 
 
 
 
 
 
 
 
 
 
 
a32aee9
f1fa34c
 
 
 
 
a32aee9
 
 
 
 
 
 
 
 
f1fa34c
 
 
 
 
 
a32aee9
 
f1fa34c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a32aee9
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
"""
API-layer configuration.

Only concerns the HTTP wrapper itself (auth, CORS, docs, limits). All
RAG/model/embedding configuration continues to live in ``app.config.Settings``
and is read from there β€” this module never duplicates it.

Fails closed: in a non-development environment the app refuses to start without
an explicit JWT secret and CORS allowlist, rather than silently using insecure
defaults.
"""

from __future__ import annotations

import secrets
from functools import lru_cache
from typing import Literal

from pydantic_settings import BaseSettings

Environment = Literal["development", "staging", "production"]


class ConfigurationError(RuntimeError):
    """Raised at startup when required production settings are missing."""


class ApiSettings(BaseSettings):
    """Settings specific to the FastAPI wrapper (read from the same .env)."""

    # Which environment we are running in. Anything other than "development"
    # enforces the production guardrails below.
    environment: Environment = "development"

    # ── Auth ─────────────────────────────────────────────────────────────────
    # MUST be set outside development. Generate with:
    #   python -c "import secrets; print(secrets.token_urlsafe(48))"
    jwt_secret: str = ""
    access_token_ttl_minutes: int = 30
    refresh_token_ttl_days: int = 30

    # ── CORS ─────────────────────────────────────────────────────────────────
    # Comma-separated browser origins. Wildcard is never permitted: this API is
    # state-changing (it can delete a user's whole library).
    #   CORS_ORIGINS=https://research-rag.vercel.app,https://researchrag.vercel.app
    cors_origins: str = ""

    # ── Docs ─────────────────────────────────────────────────────────────────
    # /docs advertises the full attack surface; off by default in production.
    expose_docs: bool = False

    api_title: str = "ResearchRAG API"
    api_version: str = "1.0.0"

    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"
        extra = "ignore"

    # ── Derived ──────────────────────────────────────────────────────────────

    @property
    def is_production(self) -> bool:
        return self.environment != "development"

    @property
    def cors_origins_list(self) -> list[str]:
        """Explicit origin allowlist. Never returns ``*``."""
        origins = [o.strip() for o in (self.cors_origins or "").split(",") if o.strip()]
        origins = [o for o in origins if o != "*"]
        if origins:
            return origins
        if self.is_production:
            raise ConfigurationError(
                "CORS_ORIGINS must list explicit https origins in "
                f"{self.environment} (wildcards are rejected)."
            )
        # Development convenience only β€” the local Vite dev server.
        return ["http://localhost:5173", "http://127.0.0.1:5173"]

    @property
    def docs_url(self) -> str | None:
        return "/docs" if (self.expose_docs or not self.is_production) else None

    @property
    def openapi_url(self) -> str | None:
        return "/openapi.json" if (self.expose_docs or not self.is_production) else None

    def validate_runtime(self) -> None:
        """
        Fail fast on missing production configuration.

        Called once at startup. In development a random secret is generated so
        local work needs no setup β€” but that secret dies with the process, which
        is exactly why it must never be relied on in production.
        """
        if self.is_production:
            if not self.jwt_secret or len(self.jwt_secret) < 32:
                raise ConfigurationError(
                    "JWT_SECRET must be set to at least 32 characters in "
                    f"{self.environment}. Generate one with: "
                    'python -c "import secrets; print(secrets.token_urlsafe(48))"'
                )
            self.cors_origins_list  # raises when unset
        elif not self.jwt_secret:
            object.__setattr__(self, "jwt_secret", secrets.token_urlsafe(48))


@lru_cache
def get_api_settings() -> ApiSettings:
    return ApiSettings()