"""AmanPay FastAPI application entry point. Run with: ``uvicorn api.main:app --host 0.0.0.0 --port 8000`` """ from __future__ import annotations import logging import os from contextlib import asynccontextmanager # Load .env (HF_TOKEN, checkpoint paths, WebAuthn config) before anything reads env. try: from dotenv import load_dotenv load_dotenv() except Exception: pass # Fully initialize huggingface_hub at import time (single-threaded) BEFORE any background thread # imports it. huggingface_hub 1.20.1 can hit a partial-init circular import # (``cannot import name 'XetConnectionInfo' from huggingface_hub.utils._xet``) when the weight # auto-download thread and the D2 bucket-store construction import it concurrently at startup — # which surfaces as ``HFBucketUnavailable`` and disables D2 persistence. Forcing the full import # chain (including the Storage Buckets API) here eliminates that race. try: import huggingface_hub # noqa: F401 from huggingface_hub import (batch_bucket_files, bucket_info, # noqa: F401 download_bucket_files, list_bucket_tree) except Exception: pass # Inference-only server tuning — helps on weak/CPU hosts (e.g. a free CPU tier). try: import torch torch.set_grad_enabled(False) torch.set_num_threads(max(1, int(os.getenv("AMANPAY_TORCH_THREADS", os.cpu_count() or 2)))) except Exception: pass from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from api.dependencies import state from api.observability import (init_sentry, metrics_middleware, metrics_response, record_readiness, setup_logging) from api.routers.notifications import router as notifications_router from api.routes import router logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") setup_logging() # switch to structured JSON logs unless disabled init_sentry() # error tracking if SENTRY_DSN is set logger = logging.getLogger("amanpay.api") @asynccontextmanager async def lifespan(app: FastAPI): """Startup must NOT block on model loading. The authenticator weights are fetched from the HF Hub and loaded lazily in a background daemon thread, so `application startup` completes immediately and the container becomes healthy right away. This prevents HF Spaces' launch health-check from timing out (and the container being killed) when a weight download is slow or hangs. Model-dependent endpoints return a clean 503 ("Model not loaded") until `state.loaded` flips true; `/healthz` and the static UI serve without the model. """ import threading config_path = os.getenv("AMANPAY_CONFIG") checkpoint = os.getenv("AMANPAY_CHECKPOINT") def _load_models() -> None: try: logger.info("Loading model in background (config=%s, checkpoint=%s)", config_path, checkpoint) state.load(config_path=config_path, checkpoint=checkpoint) logger.info("Model loaded (background); state.loaded=%s", state.loaded) except Exception as exc: # never crash startup on a load/download error logger.warning("Background model load failed (endpoints stay 503 until retried): %s", exc.__class__.__name__) # Off the startup critical path — daemon so it never blocks shutdown. if os.getenv("AMANPAY_BLOCKING_MODEL_LOAD", "0").strip().lower() in ("1", "true", "yes"): _load_models() # opt-in blocking (tests/CI parity) else: threading.Thread(target=_load_models, name="amanpay-model-load", daemon=True).start() from amanpay.version import read_build_info commit = (read_build_info(_ROOT) or {}).get("commit", "") # Programme D2 identity/accounts: build the durable runtime ONLY when explicitly enabled. # Dormant by default — no database is opened and no bucket is contacted otherwise. _d2_runtime = None from amanpay.identity.config import is_d2_enabled if is_d2_enabled(): try: from amanpay.identity.runtime import build_runtime from api.identity_routes import set_runtime _d2_runtime = build_runtime(source_commit=commit) set_runtime(_d2_runtime) logger.info("D2 identity runtime ready") except Exception as exc: # never crash startup on D2 setup failure logger.warning("D2 identity runtime unavailable (endpoints 503): %s", exc.__class__.__name__) # Programme D3 simulated finance: DORMANT by default. When enabled it shares the D2 store # (identity + finance commit atomically) and the D2 snapshot coordinator; standalone otherwise. _d3_runtime = None from amanpay.finance.config import is_d3_enabled if is_d3_enabled(): try: from amanpay.finance.runtime import build_runtime as build_d3_runtime from api.finance_routes import set_runtime as set_d3_runtime _d3_runtime = build_d3_runtime( storage=(_d2_runtime.storage if _d2_runtime is not None else None), identity=(_d2_runtime.identity if _d2_runtime is not None else None), source_commit=commit) set_d3_runtime(_d3_runtime) logger.info("D3 finance runtime ready") except Exception as exc: # never crash startup on D3 setup failure logger.warning("D3 finance runtime unavailable (endpoints 503): %s", exc.__class__.__name__) # Public-demo bootstrap (DORMANT by default): seed a demo tenant + D3 demo data so an enabled # deployment is immediately usable. Synthetic only; never touches real participant data. from amanpay.identity.config import is_demo_bootstrap if is_demo_bootstrap() and _d2_runtime is not None: try: from amanpay.demo import ensure_demo ensure_demo(_d2_runtime, _d3_runtime) logger.info("demo bootstrap complete") except Exception as exc: # never crash startup on demo seed failure logger.warning("demo bootstrap skipped: %s", exc.__class__.__name__) logger.info("Application startup complete (model loading off the critical path)") yield if _d2_runtime is not None: try: _d2_runtime.storage.coordinator.snapshot_on_shutdown() _d2_runtime.close() except Exception: # best-effort shutdown snapshot pass if _d3_runtime is not None: try: _d3_runtime.close() # only closes storage it owns (standalone) except Exception: pass logger.info("Shutting down") app = FastAPI( title="AmanPay Biometric API", description="Multi-modal (face + fingerprint) biometric authentication.", version="0.1.0", lifespan=lifespan, ) # CORS: pin origins in production (AMANPAY_ALLOWED_ORIGINS, comma-separated). The # wildcard "*" is only used when no origins are configured AND credentials are off — # "*" + credentials is invalid and unsafe. _origins = [o.strip() for o in os.getenv("AMANPAY_ALLOWED_ORIGINS", "").split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=_origins or ["*"], allow_credentials=bool(_origins), allow_methods=["*"], allow_headers=["*"], ) app.middleware("http")(metrics_middleware) # request metrics + X-Request-ID @app.get("/metrics", include_in_schema=False) def metrics(): """Prometheus metrics (request rate/latency + payment/auth domain signals).""" return metrics_response() @app.get("/healthz", include_in_schema=False) def healthz(): """Liveness: lightweight, no dependency checks.""" return {"status": "ok"} @app.get("/readyz", include_in_schema=False) def readyz(): """Readiness: verify the datastore (and Redis when REDIS_URL is set) are reachable. Returns 503 if a required dependency is down. Never exposes connection details.""" from fastapi.responses import JSONResponse checks: dict = {} try: ping = getattr(state.store, "ping", None) checks["datastore"] = bool(ping()) if ping else True except Exception: checks["datastore"] = False if os.getenv("REDIS_URL"): try: from amanpay.storage.kv import get_kv checks["redis"] = bool(get_kv().ping()) except Exception: checks["redis"] = False checks["model"] = bool(state.loaded) for dep, ok in checks.items(): record_readiness(dep, ok) ready = all(checks.values()) return JSONResponse({"ready": ready, "checks": checks}, status_code=200 if ready else 503) app.include_router(router) app.include_router(notifications_router) # torch-free /notify/* endpoints # Secure Agent Action Profile demo surface (labelled; local keys + mock agents/provider). from api.agent_security_routes import router as agent_security_router # noqa: E402 app.include_router(agent_security_router) # Agentic risk orchestration (/ai/v1 — shadow-only behavioural model, labelled demo). from amanpay.agentic_orchestration.api import router as ai_router # noqa: E402 app.include_router(ai_router) # Consent, profile-deletion and federated-status surface (/ai/v1 — advisory only, no payment authority). from amanpay.agentic_orchestration.consent_api import router as consent_router # noqa: E402 app.include_router(consent_router) # Programme D2 persistent accounts + passkeys (/identity/v1). Mounted ONLY when # AMANPAY_D2_ENABLED=1 so the surface is dormant by default; the durable runtime is built in # the lifespan startup above and injected into the router. from amanpay.identity.config import is_d2_enabled as _d2_enabled # noqa: E402 if _d2_enabled(): from api.identity_routes import router as identity_router, install_error_handler # noqa: E402 app.include_router(identity_router) install_error_handler(app) # Programme D3 simulated finance (/finance/v1). Mounted ONLY when AMANPAY_D3_ENABLED=1 so the # surface is dormant (absent -> 404) by default; the runtime is built in the lifespan above. from amanpay.finance.config import is_d3_enabled as _d3_enabled # noqa: E402 if _d3_enabled(): from api.finance_routes import (router as finance_router, # noqa: E402 install_error_handler as install_finance_errors) app.include_router(finance_router) install_finance_errors(app) _ROOT = os.path.dirname(os.path.dirname(__file__)) _FRONTEND = os.path.join(_ROOT, "frontend", "index.html") # legacy (rollback) _WEB_DIST = os.path.join(_ROOT, "web", "dist") # React/TS build def _use_react() -> bool: """React UI unless AMANPAY_UI=legacy or the build is absent (rollback-safe).""" return (os.getenv("AMANPAY_UI", "react").lower() != "legacy" and os.path.exists(os.path.join(_WEB_DIST, "index.html"))) # Serve the React build's hashed assets when present. if os.path.isdir(os.path.join(_WEB_DIST, "assets")): from fastapi.staticfiles import StaticFiles app.mount("/assets", StaticFiles(directory=os.path.join(_WEB_DIST, "assets")), name="assets") @app.get("/", include_in_schema=False) def root(): """Serve the web UI — React build by default, legacy single-file as rollback.""" from fastapi.responses import FileResponse, JSONResponse if _use_react(): return FileResponse(os.path.join(_WEB_DIST, "index.html")) if os.path.exists(_FRONTEND): return FileResponse(_FRONTEND) return JSONResponse({"name": "AmanPay Biometric API", "docs": "/docs"}) _FRONTEND_DIR = os.path.dirname(_FRONTEND) @app.get("/manifest.json", include_in_schema=False) def manifest(): """PWA manifest — makes AmanPay installable on Android/iOS home screens.""" from fastapi.responses import FileResponse, JSONResponse p = os.path.join(_FRONTEND_DIR, "manifest.json") return FileResponse(p, media_type="application/manifest+json") if os.path.exists(p) \ else JSONResponse({}, status_code=404) @app.get("/sw.js", include_in_schema=False) def service_worker(): """Service worker (must be served from scope root to control the app).""" from fastapi.responses import FileResponse, JSONResponse p = os.path.join(_FRONTEND_DIR, "sw.js") return FileResponse(p, media_type="application/javascript") if os.path.exists(p) \ else JSONResponse({}, status_code=404) @app.get("/icon-{size}.png", include_in_schema=False) def app_icon(size: str): """PWA / apple-touch icons.""" from fastapi.responses import FileResponse, JSONResponse p = os.path.join(_FRONTEND_DIR, f"icon-{size}.png") return FileResponse(p, media_type="image/png") if os.path.exists(p) \ else JSONResponse({}, status_code=404) @app.get("/info", include_in_schema=False) def info() -> dict: return {"name": "AmanPay Biometric API", "docs": "/docs", "health": "/health"} @app.get("/version") def version() -> dict: """Non-sensitive build/version info for deploy verification and the UI footer. Contains NO tokens, secrets, DB/Redis URLs, or env values.""" from amanpay.version import build_version_info try: from amanpay.payments.registry import provider_name_for provider_mode = provider_name_for("SA") except Exception: provider_mode = "mock" return build_version_info(_ROOT, ui_mode="react" if _use_react() else "legacy", provider_mode=provider_mode)