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)