amanpay / api /main.py
MHamdan's picture
CI deploy f3a9b30
3594e5c verified
Raw
History Blame Contribute Delete
13.8 kB
"""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)