File size: 18,012 Bytes
8ce332b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1533d3f
 
 
 
 
 
 
bf95443
 
 
 
 
 
 
 
 
 
8ce332b
 
5553f71
 
 
8ce332b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce1b8da
 
 
 
 
8ce332b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41f4b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8ce332b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41f4b01
8ce332b
 
 
 
 
 
 
 
 
80ee821
 
 
 
7074f09
80ee821
 
 
 
 
 
7074f09
80ee821
 
 
8ce332b
 
80ee821
 
 
 
 
 
 
 
8ce332b
 
 
 
 
 
 
 
 
 
 
 
 
 
e13b38a
8ce332b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
"""
CustomerCore REST API β€” FastAPI Application Factory (Phase 10)

This is the entry point for the entire API layer. It:
  1. Creates the FastAPI application with OpenAPI metadata
  2. Configures the application lifespan (startup/shutdown events)
  3. Registers all middleware (CORS, security headers, request ID, rate limiting)
  4. Mounts all routers (triage, health, metrics, stream)
  5. Configures global exception handlers

WHY A LIFESPAN CONTEXT MANAGER INSTEAD OF @app.on_event?
----------------------------------------------------------
FastAPI deprecated @app.on_event("startup") in favor of the lifespan context manager
(introduced in Starlette 0.20+). The lifespan pattern is cleaner:
  - Startup code runs before yield
  - Shutdown code runs after yield
  - Resources created in startup are accessible in shutdown via the same scope
  - Matches Python's standard async context manager protocol

RATE LIMITING STRATEGY:
  We use slowapi (a FastAPI wrapper around limits/redis-py) for per-tenant rate limiting.
  Rate limits are applied per tenant_id extracted from the JWT:
    - POST /triage: 100 requests/minute per tenant (generous for B2B)
    - GET /triage/{id}: 600 requests/minute (polling is frequent)
    - Global fallback: 1000 requests/minute per IP
  Why per-tenant and not per-IP?
    In B2B, one tenant may use multiple IPs (multiple offices, CDN, load balancers).
    IP-based limiting would incorrectly throttle legitimate traffic.
    Tenant-based limiting enforces fair usage across the platform.

SECURITY HEADERS (added by middleware):
  X-Content-Type-Options: nosniff    β€” prevents MIME type sniffing attacks
  X-Frame-Options: DENY              β€” prevents clickjacking
  X-XSS-Protection: 1; mode=block   β€” legacy XSS filter (modern browsers ignore this)
  Strict-Transport-Security          β€” enforces HTTPS (only active in production)
  Content-Security-Policy            β€” restricts resource loading origins
"""

from __future__ import annotations

import os
import time
import uuid
from contextlib import asynccontextmanager

import structlog
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

from src.api.routers import health, metrics, stream, triage

# ─────────────────────────────────────────────────────────────────────────────
# Structured logging setup (structlog)
# ─────────────────────────────────────────────────────────────────────────────
# structlog produces machine-readable JSON logs in production and human-readable
# colored logs in development. This is critical for log aggregation systems
# (Datadog, Grafana Loki, AWS CloudWatch) that parse JSON log lines.

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.add_log_level,
        structlog.processors.StackInfoRenderer(),
        structlog.dev.ConsoleRenderer()
        if os.getenv("APP_ENV", "development") == "development"
        else structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(20),  # INFO level
    context_class=dict,
    logger_factory=structlog.PrintLoggerFactory(),
)

log = structlog.get_logger("customercore.api")


# ─────────────────────────────────────────────────────────────────────────────
# Rate limiter
# ─────────────────────────────────────────────────────────────────────────────

def _get_tenant_or_ip(request: Request) -> str:
    """
    Rate limit key function: use tenant_id from JWT if available, else fall back to IP.
    This enables per-tenant rate limiting while still protecting unauthenticated endpoints.
    """
    # Try to extract tenant_id from Authorization header (without full JWT validation)
    # Full validation happens in the route dependencies β€” here we just need the key
    auth_header = request.headers.get("Authorization", "")
    if auth_header.startswith("Bearer "):
        try:
            import jwt as pyjwt
            token = auth_header.split(" ")[1]
            secret = os.getenv("LITELLM_MASTER_KEY", "dev-key")
            payload = pyjwt.decode(token, secret, algorithms=["HS256"],
                                   options={"verify_exp": False})
            tenant_id = payload.get("tenant_id")
            if tenant_id:
                return f"tenant:{tenant_id}"
        except Exception:
            pass
    return get_remote_address(request)


limiter = Limiter(key_func=_get_tenant_or_ip)


# ─────────────────────────────────────────────────────────────────────────────
# Application lifespan (startup / shutdown)
# ─────────────────────────────────────────────────────────────────────────────

@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    Application lifespan context manager.

    STARTUP: Initialize all stateful components (connections, caches, model clients).
    SHUTDOWN: Gracefully close connections to prevent data loss.

    The pattern mirrors how production services handle rolling deployments:
    1. New pod starts β€” lifespan startup runs
    2. Kubernetes readiness probe passes β€” traffic starts flowing to new pod
    3. Old pod receives SIGTERM β€” lifespan shutdown runs, connections drain
    4. Old pod exits cleanly
    """
    # ── Startup ────────────────────────────────────────────────────────────
    log.info("CustomerCore API starting up",
             env=os.getenv("APP_ENV", "development"),
             version="1.0.0")

    # Run database migration checks
    try:
        from src.db.migrations import run_db_migrations
        run_db_migrations()
    except Exception as exc:
        log.warning("Database migration checks failed (non-fatal)", error=str(exc))

    # Register Langfuse as LiteLLM callback (Phase 11 β€” LLM Observability)
    try:
        from src.monitoring.langfuse_tracer import setup_litellm_tracing
        tracing_enabled = setup_litellm_tracing()
        log.info("Langfuse LLM tracing",
                 enabled=tracing_enabled,
                 configured=bool(os.getenv("LANGFUSE_PUBLIC_KEY")))
    except Exception as exc:
        log.warning("Langfuse init failed (non-fatal)", error=str(exc))

    # Warm up LLM router (loads routing table into memory)
    try:
        from src.rag.router import LLMRouter, ROUTING_TABLE
        app.state.llm_router = LLMRouter()
        log.info("LLM router initialized", routing_entries=len(ROUTING_TABLE))
    except Exception as exc:
        log.warning("LLM router init failed (non-fatal)", error=str(exc))
        app.state.llm_router = None

    log.info("CustomerCore API ready to serve requests")

    yield  # ← Application runs here

    # ── Shutdown ───────────────────────────────────────────────────────────
    log.info("CustomerCore API shutting down gracefully")


# ─────────────────────────────────────────────────────────────────────────────
# FastAPI Application Factory
# ─────────────────────────────────────────────────────────────────────────────

def create_app() -> FastAPI:
    """
    Create and configure the FastAPI application.

    Using a factory function (instead of a module-level `app = FastAPI()`) allows:
      - Easy creation of test instances with different configurations
      - Clean separation between app creation and configuration
      - Future support for multiple app variants (e.g., lightweight health-only app)
    """

    app = FastAPI(
        title="CustomerCore API",
        description=(
            "Enterprise B2B AI Customer Support Platform\n\n"
            "Powered by: LangGraph 6-agent supervisor, Multi-tenant Hybrid RAG "
            "(ChromaDB + BM25 + RRF), Cryptographic Privacy Vault (AES-256-GCM), "
            "and a fine-tuned local LLM for on-premise deployment.\n\n"
            "**Authentication:** All endpoints require a JWT Bearer token. "
            "The `tenant_id` claim in the token determines data isolation boundaries.\n\n"
            "**Rate limits:** 100 triage submissions/minute per tenant."
        ),
        version="1.0.0",
        lifespan=lifespan,
        docs_url="/docs",       # Swagger UI
        redoc_url="/redoc",     # ReDoc UI (cleaner for sharing with stakeholders)
        openapi_url="/openapi.json",
        contact={
            "name": "Saibalaji Namburi",
            "url": "https://github.com/saibalajinamburi/CustomerCore",
        },
        license_info={
            "name": "MIT",
            "url": "https://opensource.org/licenses/MIT",
        },
    )

    # Attach rate limiter
    app.state.limiter = limiter
    app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

    # ── CORS Middleware ─────────────────────────────────────────────────────
    # Allow the Operations Console frontend (Phase 13) to call the API.
    # In production, replace "*" with the specific frontend origin.
    allowed_origins = os.getenv(
        "CORS_ORIGINS",
        "http://localhost:3000,http://localhost:8080,https://customercore.hf.space"
    ).split(",")

    app.add_middleware(
        CORSMiddleware,
        allow_origins=allowed_origins,
        allow_credentials=True,
        allow_methods=["GET", "POST", "OPTIONS"],
        allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
    )

    # ── Security Headers Middleware ─────────────────────────────────────────
    @app.middleware("http")
    async def security_headers_middleware(request: Request, call_next) -> Response:
        """
        Inject security headers on every response.
        These are required by OWASP and expected by enterprise security scanners.
        """
        request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
        structlog.contextvars.bind_contextvars(request_id=request_id)

        start_time = time.time()
        response: Response = await call_next(request)
        duration_ms = int((time.time() - start_time) * 1000)

        # Security headers
        response.headers["X-Request-ID"] = request_id
        response.headers["X-Content-Type-Options"] = "nosniff"
        
        # Omit X-Frame-Options in Hugging Face Spaces to allow embedding in its iframe
        if not os.getenv("SPACE_ID"):
            response.headers["X-Frame-Options"] = "DENY"
            
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"

        if os.getenv("APP_ENV") == "production":
            response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"

        # Structured request log
        log.info(
            "HTTP request",
            method=request.method,
            path=request.url.path,
            status_code=response.status_code,
            duration_ms=duration_ms,
            request_id=request_id,
        )

        structlog.contextvars.clear_contextvars()
        return response

    # ── Global Exception Handlers ───────────────────────────────────────────
    from src.db.repository import RepositoryError

    @app.exception_handler(RepositoryError)
    async def repository_error_handler(request: Request, exc: RepositoryError) -> JSONResponse:
        """
        Catch repository exceptions (database, Supabase, connections).
        Returns detailed exception details so we can debug cloud issues in real-time.
        """
        request_id = request.headers.get("X-Request-ID", "unknown")
        log.exception(
            "Repository error",
            path=request.url.path,
            request_id=request_id,
            error=str(exc),
        )
        return JSONResponse(
            status_code=500,
            content={
                "error": "Database repository error",
                "request_id": request_id,
                "detail": str(exc),
            },
        )

    @app.exception_handler(Exception)
    async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
        """
        Catch-all handler for unexpected errors.
        Returns a clean JSON error (not a raw Python traceback) and logs the full
        exception for debugging. In Phase 13, this also sends to Sentry.
        """
        request_id = request.headers.get("X-Request-ID", "unknown")
        log.exception(
            "Unhandled exception",
            path=request.url.path,
            request_id=request_id,
            error=str(exc),
        )
        return JSONResponse(
            status_code=500,
            content={
                "error": "Internal server error",
                "request_id": request_id,
                "detail": str(exc),  # Expose error detail for cloud debugging
            },
        )

    # ── Routers ─────────────────────────────────────────────────────────────
    app.include_router(health.router)
    app.include_router(triage.router)
    app.include_router(stream.router)
    app.include_router(metrics.router)

    # ── Test Token endpoint (for UI workspace) ────────────────────────────────
    @app.get("/api/v1/test-token", include_in_schema=False)
    async def get_test_token(tenant_id: str = "acme-corp", role: str = "support_agent") -> JSONResponse:
        import jwt as pyjwt
        from src.api.auth import _get_secret_key
        payload = {
            "tenant_id": tenant_id,
            "role": role,
            "iat": int(time.time()),
            "exp": int(time.time()) + 86_400,
        }
        secret = _get_secret_key()
        token = pyjwt.encode(payload, secret, algorithm="HS256")
        return JSONResponse({"token": token})

    # ── Root redirect to docs ───────────────────────────────────────────────
    @app.get("/", include_in_schema=False)
    async def root(request: Request) -> Response:
        accept = request.headers.get("accept", "")
        # Serve the premium UI console to browsers or when running in HF Spaces
        if "text/html" in accept or os.getenv("SPACE_ID"):
            from fastapi.responses import HTMLResponse
            from src.api.ui import HTML_CONTENT
            return HTMLResponse(content=HTML_CONTENT)
            
        return JSONResponse({
            "name": "CustomerCore API",
            "version": "1.0.0",
            "docs": "/docs",
            "health": "/api/v1/health",
            "github": "https://github.com/saibalajinamburi/CustomerCore",
        })

    return app


# ─────────────────────────────────────────────────────────────────────────────
# Application instance (used by uvicorn)
# ─────────────────────────────────────────────────────────────────────────────
# Trigger rebuild to load new secrets: 2026-05-29T12:57:00

app = create_app()


# ─────────────────────────────────────────────────────────────────────────────
# Development runner
# ─────────────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import uvicorn

    uvicorn.run(
        "src.api.main:app",
        host="0.0.0.0",
        port=8080,
        reload=True,          # Auto-reload on file changes during development
        log_level="info",
        access_log=False,     # Disabled β€” we have our own structured middleware logging
    )