File size: 13,781 Bytes
1730163 c4b67b0 256ac8c 1730163 544f664 60d9584 a12d188 1730163 544f664 1730163 e3a0539 1730163 e3a0539 dc47d57 ef983af dc47d57 ef983af 3594e5c e3a0539 1730163 dc47d57 ef983af 1730163 9704c6e 1730163 9704c6e 1730163 544f664 60d9584 1730163 a12d188 1730163 e411199 cd88779 0f4d9ea dc47d57 ef983af 1730163 45a105b 1730163 45a105b 1730163 45a105b 1730163 de6cac5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 | """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)
|