File size: 4,425 Bytes
b9b47ab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
"""Shared dependencies for the Phase 7 HTTP API.

Holds the single process-wide storage handle, the lazily constructed research pipeline,
and the FastAPI dependencies that turn an incoming request into either an authenticated
identity or a scoped guest identity.
"""

import re
import threading
from typing import Optional

from fastapi import Depends, Header, HTTPException, Query, status

from src.auth import UserIdentity, verify_access_token
from src.logger import get_logger
from src.pipeline import ResearchPipeline
from src.rate_limiter import RateLimiter
from src.storage import ResearchStorage

logger = get_logger(__name__)

# Guest identifiers are client-supplied, so the transport layer bounds them to a safe
# shape before they are ever used as a storage/rate-limit key.
_GUEST_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")

_storage: Optional[ResearchStorage] = None
_rate_limiter: Optional[RateLimiter] = None
_pipeline: Optional[ResearchPipeline] = None
_pipeline_lock = threading.Lock()


def get_storage() -> ResearchStorage:
    """Return the process-wide ResearchStorage handle."""
    global _storage
    if _storage is None:
        _storage = ResearchStorage()
    return _storage


def get_rate_limiter() -> RateLimiter:
    """Return the process-wide RateLimiter bound to the shared storage handle."""
    global _rate_limiter
    if _rate_limiter is None:
        _rate_limiter = RateLimiter(storage=get_storage())
    return _rate_limiter


def get_pipeline() -> ResearchPipeline:
    """Return the process-wide ResearchPipeline, constructing it on first use.

    Construction validates API keys and builds LLM clients, so it is deferred until a
    research request actually arrives - auth and history endpoints stay usable without it.
    """
    global _pipeline
    if _pipeline is None:
        with _pipeline_lock:
            if _pipeline is None:
                _pipeline = ResearchPipeline(storage=get_storage())
    return _pipeline


def reset_state(
    storage: Optional[ResearchStorage] = None,
    rate_limiter: Optional[RateLimiter] = None,
    pipeline: Optional[ResearchPipeline] = None,
) -> None:
    """Replace the process-wide handles. Used by tests to inject isolated fixtures."""
    global _storage, _rate_limiter, _pipeline
    _storage = storage
    _rate_limiter = rate_limiter if rate_limiter is not None else (
        RateLimiter(storage=storage) if storage is not None else None
    )
    _pipeline = pipeline


def _extract_bearer_token(authorization: Optional[str]) -> Optional[str]:
    """Pull the raw JWT out of an 'Authorization: Bearer <token>' header."""
    if not authorization:
        return None
    parts = authorization.split(None, 1)
    if len(parts) != 2 or parts[0].lower() != "bearer":
        return None
    token = parts[1].strip()
    return token or None


def get_optional_identity(
    authorization: Optional[str] = Header(default=None),
) -> Optional[UserIdentity]:
    """Resolve an authenticated identity if a valid bearer token was supplied.

    A malformed or expired token is an explicit error rather than a silent downgrade to
    guest: a user whose session expired must be told, not quietly de-authenticated.
    """
    token = _extract_bearer_token(authorization)
    if not token:
        return None
    return verify_access_token(token)


def require_identity(
    identity: Optional[UserIdentity] = Depends(get_optional_identity),
) -> UserIdentity:
    """Require an authenticated identity, rejecting anonymous requests with 401."""
    if identity is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Authentication required.",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return identity


def get_guest_id(
    session_id: Optional[str] = Query(
        default=None,
        description="Client-generated guest session identifier used to scope guest rate limits.",
    ),
) -> Optional[str]:
    """Validate and return the client-supplied guest session identifier."""
    if session_id is None:
        return None
    candidate = session_id.strip()
    if not candidate:
        return None
    if not _GUEST_ID_RE.match(candidate):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="session_id must be 1-64 characters of letters, digits, '-' or '_'.",
        )
    return candidate