diff --git a/.gitignore b/.gitignore
index fa70ebdb..1c4efe3f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ npm-debug.log*
/.env*.local
/.env.development
/.env.production
+backend/.env
# typescript
*.tsbuildinfo
@@ -32,6 +33,11 @@ npm-debug.log*
# Build Dir
/out
+# Playwright test artifacts
+/test-results
+/tests/screenshots
+
# python
venv
__pycache__
+playwright-report/
diff --git a/app/components/Layout/components/Footer/components/Branding/branding.tsx b/app/components/Layout/components/Footer/components/Branding/branding.tsx
index c6e83178..52e0b0bd 100644
--- a/app/components/Layout/components/Footer/components/Branding/branding.tsx
+++ b/app/components/Layout/components/Footer/components/Branding/branding.tsx
@@ -3,6 +3,7 @@ import { ANCHOR_TARGET } from "@databiosphere/findable-ui/lib/components/Links/c
import { Link } from "@databiosphere/findable-ui/lib/components/Links/components/Link/link";
import { Brands, FooterText, LargeBrand, SmallBrand } from "./branding.styles";
import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography";
+import { VersionDisplay } from "./components/VersionDisplay/versionDisplay";
export const Branding = (): JSX.Element => {
return (
@@ -54,6 +55,7 @@ export const Branding = (): JSX.Element => {
url="https://www.niaid.nih.gov/research/bioinformatics-resource-centers"
/>
+
);
};
diff --git a/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx b/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx
new file mode 100644
index 00000000..b66059c3
--- /dev/null
+++ b/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { Typography } from "@mui/material";
+import { useEffect, useState } from "react";
+import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography";
+
+const CLIENT_VERSION = process.env.NEXT_PUBLIC_VERSION || "0.15.0";
+const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "";
+
+export const VersionDisplay = (): JSX.Element => {
+ const [backendVersion, setBackendVersion] = useState(null);
+
+ useEffect(() => {
+ if (!BACKEND_URL) {
+ // No backend URL configured, skip fetching
+ return;
+ }
+
+ fetch(`${BACKEND_URL}/api/v1/version`)
+ .then((res) => res.json())
+ .then((data) => setBackendVersion(data.version))
+ .catch(() => setBackendVersion(null)); // Gracefully handle backend unavailable
+ }, []);
+
+ return (
+
+ Client build: {CLIENT_VERSION}
+ {backendVersion && ` • Server revision: ${backendVersion}`}
+
+ );
+};
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 00000000..34842948
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,35 @@
+# Python
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.Python
+.pytest_cache
+.mypy_cache
+
+# Virtual environments
+.env
+.venv
+env/
+venv/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Git
+.git
+.gitignore
+
+# Documentation
+*.md
+docs/
+
+# Tests
+tests/
\ No newline at end of file
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 00000000..f786a758
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,14 @@
+# Redis Configuration
+REDIS_URL=redis://redis:6379/0
+
+# Database Configuration (for future use)
+DATABASE_URL=postgresql://user:pass@localhost/dbname
+
+# Application Configuration
+CORS_ORIGINS=http://localhost:3000,http://localhost
+LOG_LEVEL=INFO
+ENVIRONMENT=development
+
+# Rate Limiting
+RATE_LIMIT_REQUESTS=100
+RATE_LIMIT_WINDOW=60
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 00000000..2ac07f53
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,50 @@
+# Multi-stage build for uv-based Python application
+FROM python:3.12-slim as builder
+
+# Install uv
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
+
+WORKDIR /app
+
+# Copy dependency files
+COPY pyproject.toml uv.lock ./
+
+# Install dependencies into .venv
+RUN uv sync --frozen --no-install-project
+
+# Production stage
+FROM python:3.12-slim as runtime
+
+# Accept version as build argument
+ARG APP_VERSION=0.15.0
+ENV APP_VERSION=${APP_VERSION}
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy uv for runtime
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
+
+# Create non-root user
+RUN useradd --create-home --shell /bin/bash app
+
+WORKDIR /app
+
+# Copy virtual environment from builder
+COPY --from=builder /app/.venv /app/.venv
+
+# Add venv to PATH
+ENV PATH="/app/.venv/bin:$PATH"
+
+# Copy application code
+COPY . .
+
+# Change ownership
+RUN chown -R app:app /app
+USER app
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 00000000..0d8cb8ad
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,103 @@
+# BRC Analytics Backend
+
+FastAPI backend infrastructure for BRC Analytics.
+
+## Features
+
+- FastAPI REST API
+- Redis caching with TTL support
+- Health check endpoints
+- Docker deployment with nginx reverse proxy
+- uv for dependency management
+
+## Quick Start
+
+### Development (Local)
+
+```bash
+cd backend
+uv sync
+uv run uvicorn app.main:app --reload
+```
+
+API documentation: http://localhost:8000/api/docs
+
+### Production (Docker)
+
+```bash
+# Create environment file
+cp backend/.env.example backend/.env
+# Edit backend/.env if needed (defaults work for local development)
+
+# Build with version from package.json
+./scripts/docker-build.sh
+
+# Start all services (nginx + backend + redis)
+docker compose up -d
+
+# Check service health
+curl http://localhost/api/v1/health
+
+# View logs
+docker compose logs -f backend
+
+# Rebuild after code changes
+docker compose up -d --build
+
+# Stop all services
+docker compose down
+```
+
+Services:
+
+- nginx: http://localhost (reverse proxy)
+- backend API: http://localhost:8000 (direct access)
+- API docs: http://localhost/api/docs
+- redis: localhost:6379
+
+## API Endpoints
+
+### Health & Monitoring
+
+- `GET /api/v1/health` - Overall service health status
+- `GET /api/v1/cache/health` - Redis cache connectivity check
+- `GET /api/v1/version` - API version and environment information
+
+### Documentation
+
+- `GET /api/docs` - Interactive Swagger UI
+- `GET /api/redoc` - ReDoc API documentation
+
+## Configuration
+
+Environment variables (see `.env.example`):
+
+```bash
+# Redis
+REDIS_URL=redis://localhost:6379/0
+
+# Application
+CORS_ORIGINS=http://localhost:3000,http://localhost
+LOG_LEVEL=INFO
+```
+
+## Testing
+
+```bash
+# Run e2e tests
+npm run test:e2e
+
+# Or with Playwright directly
+npx playwright test tests/e2e/03-api-health.spec.ts
+```
+
+## Architecture
+
+```
+nginx (port 80)
+ ├── /api/* → FastAPI backend (port 8000)
+ └── /* → Next.js static files
+
+FastAPI backend
+ └── Redis cache (port 6379)
+```
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 00000000..4c10750f
--- /dev/null
+++ b/backend/app/__init__.py
@@ -0,0 +1 @@
+# BRC Analytics Backend
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 00000000..28b07eff
--- /dev/null
+++ b/backend/app/api/__init__.py
@@ -0,0 +1 @@
+# API package
diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py
new file mode 100644
index 00000000..6c2f33cb
--- /dev/null
+++ b/backend/app/api/v1/__init__.py
@@ -0,0 +1 @@
+# API v1 package
diff --git a/backend/app/api/v1/cache.py b/backend/app/api/v1/cache.py
new file mode 100644
index 00000000..82d8a30c
--- /dev/null
+++ b/backend/app/api/v1/cache.py
@@ -0,0 +1,31 @@
+from fastapi import APIRouter, Depends, HTTPException
+
+from app.core.cache import CacheService
+from app.core.dependencies import get_cache_service
+
+router = APIRouter()
+
+
+@router.get("/health")
+async def cache_health(cache: CacheService = Depends(get_cache_service)):
+ """Check if cache service is healthy"""
+ try:
+ # Try to set and get a test value
+ test_key = "health_check"
+ test_value = "ok"
+
+ await cache.set(test_key, test_value, ttl=60)
+ result = await cache.get(test_key)
+ await cache.delete(test_key)
+
+ if result == test_value:
+ return {"status": "healthy", "cache": "connected"}
+ else:
+ raise HTTPException(
+ status_code=503, detail="Cache service not responding correctly"
+ )
+
+ except Exception as e:
+ raise HTTPException(
+ status_code=503, detail=f"Cache service unhealthy: {str(e)}"
+ )
diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py
new file mode 100644
index 00000000..47ee9eca
--- /dev/null
+++ b/backend/app/api/v1/health.py
@@ -0,0 +1,19 @@
+from datetime import datetime
+
+from fastapi import APIRouter
+
+from app.core.config import get_settings
+
+router = APIRouter()
+settings = get_settings()
+
+
+@router.get("/health")
+async def health_check():
+ """Health check endpoint for monitoring system status"""
+ return {
+ "status": "healthy",
+ "version": settings.APP_VERSION,
+ "timestamp": datetime.utcnow().isoformat(),
+ "service": "BRC Analytics API",
+ }
diff --git a/backend/app/api/v1/ncbi_links.py b/backend/app/api/v1/ncbi_links.py
new file mode 100644
index 00000000..e89f66a4
--- /dev/null
+++ b/backend/app/api/v1/ncbi_links.py
@@ -0,0 +1,41 @@
+"""API endpoints for NCBI cross-linking."""
+
+from fastapi import APIRouter, Depends
+
+from app.core.cache import CacheService, CacheTTL
+from app.core.dependencies import get_cache_service
+from app.services.ncbi_links_service import NCBILinksService
+
+router = APIRouter()
+
+
+@router.get("/organism-links.json")
+async def get_organism_links(cache: CacheService = Depends(get_cache_service)):
+ """Get organism links by taxonomy ID for NCBI cross-referencing"""
+ cache_key = "ncbi_links:organisms"
+
+ cached = await cache.get(cache_key)
+ if cached is not None:
+ return cached
+
+ service = NCBILinksService()
+ links = service.get_organism_links()
+ await cache.set(cache_key, links, ttl=CacheTTL.ONE_DAY)
+
+ return links
+
+
+@router.get("/assembly-links.json")
+async def get_assembly_links(cache: CacheService = Depends(get_cache_service)):
+ """Get assembly links by accession for NCBI cross-referencing"""
+ cache_key = "ncbi_links:assemblies"
+
+ cached = await cache.get(cache_key)
+ if cached is not None:
+ return cached
+
+ service = NCBILinksService()
+ links = service.get_assembly_links()
+ await cache.set(cache_key, links, ttl=CacheTTL.ONE_DAY)
+
+ return links
diff --git a/backend/app/api/v1/version.py b/backend/app/api/v1/version.py
new file mode 100644
index 00000000..45845f33
--- /dev/null
+++ b/backend/app/api/v1/version.py
@@ -0,0 +1,16 @@
+from fastapi import APIRouter
+
+from app.core.config import get_settings
+
+router = APIRouter()
+settings = get_settings()
+
+
+@router.get("")
+async def get_version():
+ """Get API version and build information"""
+ return {
+ "version": settings.APP_VERSION,
+ "environment": settings.ENVIRONMENT,
+ "service": "BRC Analytics API",
+ }
diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 00000000..d61a2551
--- /dev/null
+++ b/backend/app/core/__init__.py
@@ -0,0 +1 @@
+# Core package
diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py
new file mode 100644
index 00000000..bcec835b
--- /dev/null
+++ b/backend/app/core/cache.py
@@ -0,0 +1,118 @@
+import hashlib
+import json
+import logging
+from datetime import timedelta
+from typing import Any, Dict, Optional
+
+import redis.asyncio as redis
+
+logger = logging.getLogger(__name__)
+
+
+class CacheService:
+ """Redis-based cache service with TTL support and key management"""
+
+ def __init__(self, redis_url: str):
+ self.redis = redis.from_url(redis_url, decode_responses=True)
+
+ async def get(self, key: str) -> Optional[Any]:
+ """Get a value from cache by key"""
+ try:
+ value = await self.redis.get(key)
+ if value:
+ return json.loads(value)
+ return None
+ except (redis.RedisError, json.JSONDecodeError) as e:
+ logger.error(f"Cache get error for key {key}: {e}")
+ return None
+
+ async def set(self, key: str, value: Any, ttl: int = 3600) -> bool:
+ """Set a value in cache with TTL (time to live) in seconds"""
+ try:
+ serialized_value = json.dumps(value, default=str)
+ await self.redis.setex(key, ttl, serialized_value)
+ return True
+ except (redis.RedisError, TypeError) as e:
+ logger.error(f"Cache set error for key {key}: {e}")
+ return False
+
+ async def delete(self, key: str) -> bool:
+ """Delete a key from cache"""
+ try:
+ result = await self.redis.delete(key)
+ return result > 0
+ except redis.RedisError as e:
+ logger.error(f"Cache delete error for key {key}: {e}")
+ return False
+
+ async def exists(self, key: str) -> bool:
+ """Check if key exists in cache"""
+ try:
+ return await self.redis.exists(key) > 0
+ except redis.RedisError as e:
+ logger.error(f"Cache exists error for key {key}: {e}")
+ return False
+
+ async def get_ttl(self, key: str) -> int:
+ """Get remaining TTL for a key (-1 if no TTL, -2 if key doesn't exist)"""
+ try:
+ return await self.redis.ttl(key)
+ except redis.RedisError as e:
+ logger.error(f"Cache TTL error for key {key}: {e}")
+ return -2
+
+ async def clear_pattern(self, pattern: str) -> int:
+ """Clear all keys matching a pattern"""
+ try:
+ keys = await self.redis.keys(pattern)
+ if keys:
+ return await self.redis.delete(*keys)
+ return 0
+ except redis.RedisError as e:
+ logger.error(f"Cache clear pattern error for {pattern}: {e}")
+ return 0
+
+ async def get_stats(self) -> Dict[str, Any]:
+ """Get cache statistics"""
+ try:
+ info = await self.redis.info()
+ return {
+ "hits": info.get("keyspace_hits", 0),
+ "misses": info.get("keyspace_misses", 0),
+ "hit_rate": self._calculate_hit_rate(info),
+ "memory_used": info.get("used_memory_human", "0B"),
+ "memory_used_bytes": info.get("used_memory", 0),
+ "keys_count": await self.redis.dbsize(),
+ "connected_clients": info.get("connected_clients", 0),
+ }
+ except redis.RedisError as e:
+ logger.error(f"Cache stats error: {e}")
+ return {}
+
+ def _calculate_hit_rate(self, info: Dict) -> float:
+ """Calculate cache hit rate from Redis info"""
+ hits = info.get("keyspace_hits", 0)
+ misses = info.get("keyspace_misses", 0)
+ total = hits + misses
+ return (hits / total) if total > 0 else 0.0
+
+ def make_key(self, prefix: str, params: Dict[str, Any]) -> str:
+ """Generate a cache key from prefix and parameters"""
+ # Sort parameters for consistent keys
+ param_str = json.dumps(params, sort_keys=True, default=str)
+ hash_val = hashlib.md5(param_str.encode()).hexdigest()[:16]
+ return f"{prefix}:{hash_val}"
+
+ async def close(self):
+ """Close Redis connection"""
+ await self.redis.close()
+
+
+# Cache TTL constants (in seconds)
+class CacheTTL:
+ FIVE_MINUTES = 300
+ ONE_HOUR = 3600
+ SIX_HOURS = 21600
+ ONE_DAY = 86400
+ ONE_WEEK = 604800
+ THIRTY_DAYS = 2592000
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
new file mode 100644
index 00000000..c6dd2498
--- /dev/null
+++ b/backend/app/core/config.py
@@ -0,0 +1,40 @@
+import os
+from functools import lru_cache
+from typing import List
+
+
+class Settings:
+ """Application settings loaded from environment variables"""
+
+ # Application
+ APP_VERSION: str = os.getenv("APP_VERSION", "0.15.0")
+
+ # Redis settings
+ REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
+
+ # Database settings (for future use)
+ DATABASE_URL: str = os.getenv("DATABASE_URL", "")
+
+ # CORS settings
+ CORS_ORIGINS: List[str] = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(
+ ","
+ )
+
+ # Logging
+ LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
+
+ # Environment
+ ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
+
+ # Rate limiting
+ RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
+ RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds
+
+ # Catalog path
+ CATALOG_PATH: str = os.getenv("CATALOG_PATH", "/catalog/output")
+
+
+@lru_cache()
+def get_settings() -> Settings:
+ """Get cached settings instance"""
+ return Settings()
diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py
new file mode 100644
index 00000000..b49924b6
--- /dev/null
+++ b/backend/app/core/dependencies.py
@@ -0,0 +1,16 @@
+from functools import lru_cache
+
+from app.core.cache import CacheService
+from app.core.config import get_settings
+
+# Global cache service instance
+_cache_service = None
+
+
+async def get_cache_service() -> CacheService:
+ """Dependency to get cache service instance"""
+ global _cache_service
+ if _cache_service is None:
+ settings = get_settings()
+ _cache_service = CacheService(settings.REDIS_URL)
+ return _cache_service
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 00000000..75023dde
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,33 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.api.v1 import cache, health, ncbi_links, version
+from app.core.config import get_settings
+
+settings = get_settings()
+
+app = FastAPI(
+ title="BRC Analytics API",
+ version=settings.APP_VERSION,
+ docs_url="/api/docs",
+ redoc_url="/api/redoc",
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.CORS_ORIGINS,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# Include routers
+app.include_router(health.router, prefix="/api/v1", tags=["health"])
+app.include_router(cache.router, prefix="/api/v1/cache", tags=["cache"])
+app.include_router(version.router, prefix="/api/v1/version", tags=["version"])
+app.include_router(ncbi_links.router, prefix="/api/v1/links", tags=["ncbi-links"])
+
+
+@app.get("/")
+async def root():
+ return {"message": "BRC Analytics API", "version": settings.APP_VERSION}
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 00000000..a70b3029
--- /dev/null
+++ b/backend/app/services/__init__.py
@@ -0,0 +1 @@
+# Services package
diff --git a/backend/app/services/ncbi_links_service.py b/backend/app/services/ncbi_links_service.py
new file mode 100644
index 00000000..9ecfbbde
--- /dev/null
+++ b/backend/app/services/ncbi_links_service.py
@@ -0,0 +1,74 @@
+"""Service for generating NCBI link data from BRC Analytics catalog."""
+
+import json
+import logging
+from pathlib import Path
+from typing import Any, Dict, List
+
+from app.core.config import get_settings
+
+logger = logging.getLogger(__name__)
+
+
+class NCBILinksService:
+ """Service to generate link files for NCBI cross-referencing."""
+
+ def __init__(self, catalog_path: str | None = None):
+ settings = get_settings()
+ self.catalog_path = Path(catalog_path or settings.CATALOG_PATH)
+ self.base_url = "https://brc-analytics.org"
+
+ def _load_json_file(self, filename: str) -> List[Dict[str, Any]]:
+ file_path = self.catalog_path / filename
+ try:
+ with open(file_path, "r") as f:
+ return json.load(f)
+ except FileNotFoundError:
+ logger.error(f"Catalog file not found: {file_path}")
+ return []
+ except json.JSONDecodeError as e:
+ logger.error(f"Error parsing JSON from {file_path}: {e}")
+ return []
+
+ def get_organism_links(self) -> List[Dict[str, str]]:
+ organisms = self._load_json_file("organisms.json")
+ links = []
+
+ for org in organisms:
+ taxonomy_id = org.get("ncbiTaxonomyId")
+ if not taxonomy_id:
+ continue
+
+ links.append(
+ {
+ "ncbiTaxonomyId": taxonomy_id,
+ "url": f"{self.base_url}/data/organisms/{taxonomy_id}",
+ "scientificName": org.get("taxonomicLevelSpecies"),
+ "commonName": org.get("commonName"),
+ }
+ )
+
+ logger.info(f"Generated {len(links)} organism links")
+ return links
+
+ def get_assembly_links(self) -> List[Dict[str, str]]:
+ assemblies = self._load_json_file("assemblies.json")
+ links = []
+
+ for assembly in assemblies:
+ accession = assembly.get("accession")
+ if not accession:
+ continue
+
+ url_accession = accession.replace(".", "_")
+ links.append(
+ {
+ "accession": accession,
+ "url": f"{self.base_url}/data/assemblies/{url_accession}",
+ "ncbiTaxonomyId": assembly.get("ncbiTaxonomyId"),
+ "scientificName": assembly.get("taxonomicLevelSpecies"),
+ }
+ )
+
+ logger.info(f"Generated {len(links)} assembly links")
+ return links
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 00000000..c9474e35
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,32 @@
+[project]
+name = "brc-analytics-backend"
+version = "0.1.0"
+description = "FastAPI backend infrastructure for BRC Analytics"
+authors = [
+ {name = "BRC Team"}
+]
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+ "fastapi>=0.116.1",
+ "uvicorn>=0.35.0",
+ "redis>=6.4.0",
+ "httpx>=0.28.1",
+ "pydantic>=2.11.7",
+ "python-dotenv>=1.1.1"
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.4.1",
+ "pytest-asyncio>=1.1.0",
+ "ruff>=0.13.0"
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["app"]
+
diff --git a/backend/ruff.toml b/backend/ruff.toml
new file mode 100644
index 00000000..bf02cb1b
--- /dev/null
+++ b/backend/ruff.toml
@@ -0,0 +1,12 @@
+# Ruff configuration for backend
+# This prevents ruff from trying to parse poetry's pyproject.toml
+target-version = "py312"
+line-length = 88
+
+[format]
+quote-style = "double"
+indent-style = "space"
+
+[lint]
+select = ["E", "F", "I", "B"]
+fixable = ["ALL"]
diff --git a/backend/uv.lock b/backend/uv.lock
new file mode 100644
index 00000000..a96f5410
--- /dev/null
+++ b/backend/uv.lock
@@ -0,0 +1,372 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "sniffio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
+]
+
+[[package]]
+name = "brc-analytics-backend"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+ { name = "fastapi" },
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "redis" },
+ { name = "uvicorn" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "fastapi", specifier = ">=0.116.1" },
+ { name = "httpx", specifier = ">=0.28.1" },
+ { name = "pydantic", specifier = ">=2.11.7" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1" },
+ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.1.0" },
+ { name = "python-dotenv", specifier = ">=1.1.1" },
+ { name = "redis", specifier = ">=6.4.0" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.13.0" },
+ { name = "uvicorn", specifier = ">=0.35.0" },
+]
+provides-extras = ["dev"]
+
+[[package]]
+name = "certifi"
+version = "2025.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.118.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536, upload-time = "2025-09-29T03:37:23.126Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694, upload-time = "2025-09-29T03:37:21.338Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.11.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.33.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
+ { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
+ { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
+ { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
+ { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
+ { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
+ { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
+ { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
+]
+
+[[package]]
+name = "redis"
+version = "6.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.13.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" },
+ { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" },
+ { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" },
+ { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" },
+ { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" },
+ { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" },
+ { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" },
+ { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "0.48.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" },
+]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..aec679f5
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,62 @@
+services:
+ nginx:
+ image: nginx:alpine
+ ports:
+ - "80:80"
+ volumes:
+ - ./nginx.conf:/etc/nginx/conf.d/default.conf
+ - ./out:/usr/share/nginx/html
+ depends_on:
+ backend:
+ condition: service_healthy
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:80/api/v1/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ args:
+ APP_VERSION: ${APP_VERSION:-0.15.0}
+ ports:
+ - "8000:8000"
+ env_file:
+ - ./backend/.env
+ environment:
+ # Only override what needs Docker networking
+ REDIS_URL: redis://redis:6379/0
+ volumes:
+ - ./catalog:/catalog:ro
+ depends_on:
+ redis:
+ condition: service_healthy
+ restart: unless-stopped
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+ command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
+ volumes:
+ - redis_data:/data
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 15s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+
+volumes:
+ redis_data:
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 00000000..ce5b8787
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,6 @@
+// Jest was configured in 74d3967f (Sept 2024) but no Jest tests were ever written.
+// This config excludes Playwright e2e tests which Jest cannot parse.
+module.exports = {
+ testEnvironment: "jsdom",
+ testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"],
+};
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 00000000..d4371d09
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,66 @@
+upstream backend {
+ server backend:8000;
+}
+
+server {
+ listen 80;
+ server_name localhost;
+
+ # Gzip compression
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_types
+ application/json
+ application/javascript
+ text/css
+ text/javascript
+ text/plain
+ text/xml;
+
+ # Security headers
+ add_header X-Content-Type-Options nosniff;
+ add_header X-Frame-Options DENY;
+ add_header X-XSS-Protection "1; mode=block";
+
+ # API routes - proxy to FastAPI backend
+ location /api {
+ proxy_pass http://backend;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+ proxy_read_timeout 300;
+ proxy_connect_timeout 300;
+ proxy_send_timeout 300;
+ }
+
+ # Static files - serve Next.js build output
+ location / {
+ root /usr/share/nginx/html;
+ try_files $uri $uri.html $uri/ /index.html;
+
+ # Cache static assets
+ location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # Cache HTML files for a shorter period
+ location ~* \.html$ {
+ expires 1h;
+ add_header Cache-Control "public";
+ }
+ }
+
+ # Health check endpoint
+ location /health {
+ access_log off;
+ return 200 "healthy\n";
+ add_header Content-Type text/plain;
+ }
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 2d9e5731..ee2f9067 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"format": "prettier --write . --cache",
"format:python": "./scripts/format-python.sh",
"prepare": "husky",
- "test": "jest --runInBand",
+ "test": "jest --runInBand --passWithNoTests",
"build-brc-db": "esrun catalog/build/ts/build-catalog.ts",
"build-ga2-db": "esrun catalog/ga2/build/ts/build-catalog.ts",
"build-brc-from-ncbi": "python3 -m catalog.build.py.build_files_from_ncbi",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 00000000..a6b16332
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,39 @@
+import { defineConfig, devices } from "@playwright/test";
+
+/**
+ * Playwright configuration for BRC Analytics tests
+ */
+export default defineConfig({
+ forbidOnly: !!process.env.CI,
+ fullyParallel: false,
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+ reporter: "html",
+ retries: process.env.CI ? 2 : 0,
+ testDir: "./tests/e2e",
+ use: {
+ baseURL: "http://localhost:3000",
+ screenshot: "only-on-failure",
+ trace: "on-first-retry",
+ video: "retain-on-failure",
+ },
+ webServer: [
+ {
+ command: "npm run dev",
+ reuseExistingServer: true,
+ timeout: 120 * 1000,
+ url: "http://localhost:3000",
+ },
+ {
+ command: "docker-compose up",
+ reuseExistingServer: true,
+ timeout: 120 * 1000,
+ url: "http://localhost:8000/api/v1/health",
+ },
+ ],
+ workers: 1,
+});
diff --git a/pyproject.toml b/pyproject.toml
index d2cd8cad..e69f26a1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,6 +2,7 @@
target-version = "py38"
line-length = 88
indent-width = 4
+extend-exclude = ["backend/pyproject.toml", "backend/poetry.lock"]
[tool.ruff.format]
quote-style = "double"
diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh
new file mode 100755
index 00000000..1c00d03d
--- /dev/null
+++ b/scripts/docker-build.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+# Build Docker images with version from package.json
+VERSION=$(node -p "require('./package.json').version")
+export APP_VERSION=$VERSION
+
+echo "Building with version: $VERSION"
+docker compose build "$@"
diff --git a/scripts/format-python.sh b/scripts/format-python.sh
index 960f7ed5..93f7400b 100755
--- a/scripts/format-python.sh
+++ b/scripts/format-python.sh
@@ -2,11 +2,11 @@
# Format Python files using Ruff (Rust-based formatter)
echo "Formatting Python files with Ruff..."
-ruff format catalog/
+ruff format catalog/ backend/
# Sort imports with Ruff
echo "Sorting imports with Ruff..."
-ruff check --select I --fix catalog/
+ruff check --select I --fix catalog/ backend/
# Exit with Ruff's status code
exit $?
\ No newline at end of file
diff --git a/site-config/brc-analytics/local/.env b/site-config/brc-analytics/local/.env
index 11329b58..6cf1791e 100644
--- a/site-config/brc-analytics/local/.env
+++ b/site-config/brc-analytics/local/.env
@@ -1,3 +1,4 @@
NEXT_PUBLIC_ENA_PROXY_DOMAIN="https://brc-analytics.dev.clevercanary.com"
NEXT_PUBLIC_SITE_CONFIG='brc-analytics-local'
NEXT_PUBLIC_GALAXY_ENV="TEST"
+NEXT_PUBLIC_BACKEND_URL="http://localhost:8000"
diff --git a/tests/e2e/03-api-health.spec.ts b/tests/e2e/03-api-health.spec.ts
new file mode 100644
index 00000000..d226146f
--- /dev/null
+++ b/tests/e2e/03-api-health.spec.ts
@@ -0,0 +1,57 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("BRC Analytics - API Infrastructure", () => {
+ test("backend API should be healthy", async ({ request }) => {
+ // Check backend health endpoint
+ const response = await request.get("http://localhost:8000/api/v1/health");
+ expect(response.ok()).toBeTruthy();
+
+ const health = await response.json();
+ expect(health.status).toBe("healthy");
+ expect(health.service).toBe("BRC Analytics API");
+ console.log("Backend health:", health);
+ });
+
+ test("cache health check should work", async ({ request }) => {
+ // Check cache health endpoint
+ const response = await request.get(
+ "http://localhost:8000/api/v1/cache/health"
+ );
+ expect(response.ok()).toBeTruthy();
+
+ const health = await response.json();
+ expect(health.status).toBe("healthy");
+ expect(health.cache).toBe("connected");
+ console.log("Cache health:", health);
+ });
+
+ test("version endpoint should return version info", async ({ request }) => {
+ // Check version endpoint
+ const response = await request.get("http://localhost:8000/api/v1/version");
+ expect(response.ok()).toBeTruthy();
+
+ const version = await response.json();
+ expect(version.version).toBeTruthy();
+ expect(version.environment).toBeTruthy();
+ expect(version.service).toBe("BRC Analytics API");
+ console.log("API version:", version);
+ });
+
+ test("API documentation should be accessible", async ({ page }) => {
+ // Navigate to API docs
+ await page.goto("http://localhost:8000/api/docs");
+
+ // Check that Swagger UI loaded
+ await expect(page.locator(".swagger-ui")).toBeVisible({ timeout: 10000 });
+
+ // Check for API title
+ const title = page.locator(".title");
+ await expect(title).toContainText("BRC Analytics API");
+
+ // Screenshot the API docs
+ await page.screenshot({
+ fullPage: true,
+ path: "tests/screenshots/api-docs.png",
+ });
+ });
+});
diff --git a/tests/e2e/04-version-display.spec.ts b/tests/e2e/04-version-display.spec.ts
new file mode 100644
index 00000000..6244a3dd
--- /dev/null
+++ b/tests/e2e/04-version-display.spec.ts
@@ -0,0 +1,42 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("Version Display", () => {
+ test("should show client version and backend version in footer", async ({
+ page,
+ }) => {
+ await page.goto("http://localhost:3000");
+
+ // Wait for the footer to be visible
+ const footer = page.locator("footer");
+ await expect(footer).toBeVisible();
+
+ // Check for client version (always present)
+ await expect(footer).toContainText("Client build:");
+
+ // Check for backend version (should appear after API call)
+ await expect(footer).toContainText("Server revision:", { timeout: 5000 });
+
+ // Verify both show 0.15.0
+ const versionText = await footer.textContent();
+ console.log("Version display:", versionText);
+ expect(versionText).toMatch(/Client build:.*0\.15\.0/);
+ expect(versionText).toMatch(/Server revision:.*0\.15\.0/);
+ });
+
+ test("should gracefully handle backend unavailable", async ({ page }) => {
+ // Block the API call to simulate backend unavailable
+ await page.route("**/api/v1/version", (route) => route.abort("failed"));
+
+ await page.goto("http://localhost:3000");
+
+ const footer = page.locator("footer");
+ await expect(footer).toBeVisible();
+
+ // Should still show client version
+ await expect(footer).toContainText("Client build:");
+
+ // Should NOT show server revision
+ const versionText = await footer.textContent();
+ expect(versionText).not.toContain("Server revision:");
+ });
+});