amanpay / api /routes.py
MHamdan's picture
CI deploy f7ddbcd
a12d188 verified
Raw
History Blame Contribute Delete
28.7 kB
"""API endpoints for enrollment, authentication and verification."""
from __future__ import annotations
import logging
import base64
import json
import os
import time
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from amanpay.models.authenticator import AmanPayAuthenticator
from api.auth import authorize, mint_token, rate_limit, require_auth
from api.observability import record_auth, record_payment
from api.dependencies import (
decode_image,
get_model,
preprocess_face,
preprocess_fingerprint,
preprocess_voice,
state,
)
from api.schemas import (
AuthRequest,
AuthResponse,
ChallengeRequest,
ChallengeResponse,
EnrollRequest,
EnrollResponse,
HealthResponse,
PasskeyAuthRequest,
PasskeyAuthResponse,
PasskeyRegisterRequest,
UserListResponse,
VerifyRequest,
VerifyResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
return HealthResponse(status="ok", model_loaded=state.loaded, device=state.device)
@router.get("/biometrics/status")
def biometrics_status() -> dict:
"""Per-modality capability so the React biometric UI can show available /
demo_untrained / unavailable per service. Booleans + labels only — no secrets."""
return state.biometric_status()
@router.post("/enroll", response_model=EnrollResponse)
def enroll(req: EnrollRequest,
model: AmanPayAuthenticator = Depends(get_model)) -> EnrollResponse:
face = preprocess_face(decode_image(req.face_image))
fp = preprocess_fingerprint(decode_image(req.fingerprint_image))
result = model.enroll(req.user_id, face, fp)
return EnrollResponse(
success=result["success"],
user_id=req.user_id,
face_liveness=result.get("face_liveness", 0.0),
fp_liveness=result.get("fp_liveness", 0.0),
reason=result.get("reason"),
)
@router.post("/authenticate", response_model=AuthResponse)
def authenticate(req: AuthRequest,
model: AmanPayAuthenticator = Depends(get_model)) -> AuthResponse:
face_img = decode_image(req.face_image)
df = state.deepfake_score(face_img) # P(attack) or None
is_df = df is not None and df >= state.deepfake_threshold
face = preprocess_face(face_img)
fp = preprocess_fingerprint(decode_image(req.fingerprint_image))
result = model.authenticate(req.user_id, face, fp)
success = result.get("success", False) and not is_df
reason = "Deepfake/injection detected" if is_df else result.get("reason")
return AuthResponse(
success=success,
similarity=result.get("similarity", 0.0),
threshold=result.get("threshold", model.similarity_threshold),
face_liveness=result.get("face_liveness", 0.0),
fp_liveness=result.get("fp_liveness", 0.0),
confidence=result.get("confidence"),
face_quality=result.get("face_quality"),
fp_quality=result.get("fp_quality"),
protected=result.get("protected"),
deepfake_score=df,
is_deepfake=is_df,
reason=reason,
)
@router.post("/verify", response_model=VerifyResponse)
def verify(req: VerifyRequest,
model: AmanPayAuthenticator = Depends(get_model)) -> VerifyResponse:
face1 = preprocess_face(decode_image(req.face1))
fp1 = preprocess_fingerprint(decode_image(req.fp1))
face2 = preprocess_face(decode_image(req.face2))
fp2 = preprocess_fingerprint(decode_image(req.fp2))
result = model.verify(face1, fp1, face2, fp2)
return VerifyResponse(**result)
@router.post("/passkey/register")
def passkey_register(req: PasskeyRegisterRequest) -> dict:
import base64
state.passkeys.register(req.user_id, base64.b64decode(req.public_key))
return {"success": True, "user_id": req.user_id}
@router.post("/passkey/challenge", response_model=ChallengeResponse)
def passkey_challenge(req: ChallengeRequest) -> ChallengeResponse:
import base64
import time
nonce = state.passkeys.issue_challenge(req.user_id, now=time.time())
if nonce is None:
raise HTTPException(status_code=404, detail="device not registered")
return ChallengeResponse(user_id=req.user_id,
challenge=base64.b64encode(nonce).decode())
@router.post("/passkey/authenticate", response_model=PasskeyAuthResponse)
def passkey_authenticate(req: PasskeyAuthRequest,
model: AmanPayAuthenticator = Depends(get_model)) -> PasskeyAuthResponse:
"""Two-factor: multimodal biometric (inherence) + device-key signature
over a single-use challenge (possession)."""
import base64
import time
face = preprocess_face(decode_image(req.face_image))
fp = preprocess_fingerprint(decode_image(req.fingerprint_image))
state.ensure_user_loaded(req.user_id)
bio = model.authenticate(req.user_id, face, fp)
bio_ok = bool(bio.get("success", False))
r = state.passkeys.verify_assertion(
req.user_id, base64.b64decode(req.challenge),
base64.b64decode(req.signature), bio_ok, now=time.time())
return PasskeyAuthResponse(
success=r["success"], biometric_success=bio_ok,
similarity=bio.get("similarity", 0.0),
factors=r.get("factors"), reason=r.get("reason"))
def _b64(s: str) -> bytes:
if isinstance(s, str) and s.startswith("data:") and "," in s:
s = s.split(",", 1)[1]
return base64.b64decode(s)
# ---- Voice passphrase factor ----
@router.post("/voice/enroll")
def voice_enroll(body: dict = Body(...)) -> dict:
return state.voice.enroll(body["user_id"], _b64(body["audio"]))
@router.post("/voice/verify")
def voice_verify(body: dict = Body(...)) -> dict:
return state.voice.verify(body["user_id"], _b64(body["audio"]))
# ---- Server-issued active-liveness challenge ----
@router.post("/liveness/challenge")
def liveness_challenge(body: dict = Body(...)) -> dict:
return state.liveness.issue(body.get("user_id", "user"),
n=int(body.get("n", 3)), now=time.time())
@router.post("/liveness/verify")
def liveness_verify(body: dict = Body(...)) -> dict:
df_ok = True
if body.get("face_image"):
s = state.deepfake_score(decode_image(body["face_image"]))
df_ok = s is None or s < state.deepfake_threshold
return state.liveness.verify(body["session_id"], body.get("completed", []),
now=time.time(), deepfake_ok=df_ok)
# ---- Server-side WebAuthn (real FIDO2 ceremony) ----
def _webauthn(request: Request | None = None):
if state.webauthn is None:
from amanpay.security.webauthn_server import WebAuthnServer
state.webauthn = WebAuthnServer()
# Unless pinned via env, derive rp_id/origin from the request host so passkeys
# work on whatever domain serves the app. To prevent host-header injection driving
# the ceremony, only derive when the Host is in AMANPAY_ALLOWED_HOSTS (if that env
# is set); otherwise trust the request host only for localhost.
if request is not None and not os.getenv("AMANPAY_RP_ID"):
host = (request.headers.get("host") or "").split(":")[0]
allowed = [h.strip() for h in os.getenv("AMANPAY_ALLOWED_HOSTS", "").split(",") if h.strip()]
ok = host and (host == "localhost" or not allowed or host in allowed)
if ok:
state.webauthn.rp_id = host
state.webauthn.origin = ("https://localhost:8443" if host == "localhost"
else f"https://{host}")
return state.webauthn
@router.post("/webauthn/register/begin")
def wa_register_begin(request: Request, body: dict = Body(...)) -> dict:
return json.loads(_webauthn(request).register_begin(body["user_id"]))
@router.post("/webauthn/register/complete")
def wa_register_complete(request: Request, body: dict = Body(...)) -> dict:
try:
r = _webauthn(request).register_complete(body["user_id"], body["credential"])
state.persist() # store the device passkey (public key) durably
return r
except Exception as exc: # attestation/verification errors -> clean 200 failure
return {"success": False, "reason": str(exc)}
@router.post("/webauthn/authenticate/begin")
def wa_auth_begin(request: Request, body: dict = Body(...)) -> dict:
state.ensure_user_loaded(body["user_id"])
opts = _webauthn(request).authenticate_begin(body["user_id"])
if opts is None:
raise HTTPException(status_code=404, detail="no registered device")
return json.loads(opts)
@router.post("/webauthn/authenticate/complete")
def wa_auth_complete(request: Request, body: dict = Body(...)) -> dict:
state.ensure_user_loaded(body["user_id"])
try:
return _webauthn(request).authenticate_complete(body["user_id"], body["credential"])
except Exception as exc:
return {"success": False, "reason": str(exc)}
# ---- Unified tri-modal identity (face + fingerprint + voice) ----
def _unified_samples(body: dict) -> dict:
s = {}
if body.get("face_image"):
s["face"] = preprocess_face(decode_image(body["face_image"]))
if body.get("fingerprint_image"):
s["fingerprint"] = preprocess_fingerprint(decode_image(body["fingerprint_image"]))
if body.get("voice_audio"):
s["voice"] = preprocess_voice(_b64(body["voice_audio"]))
return s
@router.post("/unified/enroll")
def unified_enroll(request: Request, body: dict = Body(...)) -> dict:
if state.unified is None:
raise HTTPException(status_code=503, detail="unified model not loaded")
rate_limit(request, "enroll", capacity=6, refill_per_sec=0.2)
r = state.unified.enroll(body["user_id"], **_unified_samples(body))
state.persist()
state.audit(body["user_id"], "enroll", {"modalities": r.get("modalities")})
# Issue a session token so the client can call user-scoped endpoints when
# AMANPAY_REQUIRE_AUTH is enabled.
r["token"] = mint_token(body["user_id"])
return r
@router.post("/unified/authenticate")
def unified_authenticate(request: Request, body: dict = Body(...)) -> dict:
if state.unified is None:
raise HTTPException(status_code=503, detail="unified model not loaded")
authorize(body["user_id"], request)
rate_limit(request, "auth", capacity=8, refill_per_sec=0.3)
state.ensure_user_loaded(body["user_id"])
result = state.unified.authenticate(body["user_id"], **_unified_samples(body))
record_auth("success" if result.get("success") else "fail")
# deepfake/injection gate on the face modality
if body.get("face_image"):
df = state.deepfake_score(decode_image(body["face_image"]))
if df is not None:
result["deepfake_score"] = df
if df >= state.deepfake_threshold:
result["success"] = False
result["reason"] = "Deepfake/injection detected"
return result
# ---- Active screen-flash liveness (Flashmark-style, defeats injection) ----
@router.post("/pad/challenge")
def pad_challenge(body: dict = Body(...)) -> dict:
"""Issue a one-time random screen-colour sequence. The client flashes each
colour from the screen during face capture and reports the reflected colours."""
return state.flash.issue(body.get("user_id", "user"),
n=int(body.get("n", 4)), now=time.time())
@router.post("/pad/verify")
def pad_verify(body: dict = Body(...)) -> dict:
"""Verify captured face-region reflection against the issued flash sequence."""
return state.flash.verify(body["session_id"], body["baseline"],
body["measurements"], now=time.time())
# ---- Notification preferences + out-of-band payment confirmation ----
# Notification / out-of-band confirmation endpoints (/notify/*) live in the torch-free
# api.routers.notifications router (included by api.main), so they and their API tests
# stay isolated from the biometric-model imports. Paths/contracts are unchanged.
# ---- Device-native biometric (platform authenticator: Touch ID / Face ID / fingerprint) ----
@router.post("/device/verify")
def device_verify(request: Request, body: dict = Body(...)) -> dict:
"""Verify a WebAuthn platform-authenticator assertion (the device's built-in
fingerprint / Face ID) over a single-use challenge — strong possession +
on-device inherence that composes with AmanPay's server-side multimodal auth."""
import base64
import time
authorize(body["user_id"], request)
rate_limit(request, "device", capacity=8, refill_per_sec=0.3)
state.ensure_user_loaded(body["user_id"])
r = state.passkeys.verify_assertion(
body["user_id"], base64.b64decode(body["challenge"]),
base64.b64decode(body["signature"]), True, now=time.time())
return {"success": bool(r.get("success")), "authenticator": "platform",
"factors": r.get("factors"), "reason": r.get("reason")}
# ---- Cardless biometric wallet (no physical card) ----
@router.post("/wallet/consent")
def wallet_consent(request: Request, body: dict = Body(...)) -> dict:
authorize(body["user_id"], request)
return state.wallet.record_consent(
body["user_id"], body.get("purpose", "biometric payment authentication"),
body.get("duration", "until account closure"), now=time.time())
@router.post("/wallet/link")
def wallet_link(request: Request, body: dict = Body(...)) -> dict:
authorize(body["user_id"], request)
r = state.wallet.link_account(
body["user_id"], body.get("bank", "Bank"), body.get("kind", "checking"),
body.get("funding_ref", "0000"), now=time.time())
state.persist()
return r
@router.get("/wallet/accounts")
def wallet_accounts(user_id: str, request: Request) -> dict:
authorize(user_id, request)
return {"accounts": state.wallet.accounts(user_id)}
@router.get("/wallet/transactions")
def wallet_transactions(user_id: str, request: Request) -> dict:
authorize(user_id, request)
return {"transactions": state.wallet.transactions(user_id)}
@router.post("/wallet/pay")
def wallet_pay(request: Request, body: dict = Body(...)) -> dict:
"""Authorize a payment purely by biometrics: tri-modal verification (inherence)
on a device-bound app (possession) = PSD2 SCA, signed to the transaction (SPC).
Risk-adaptive (Visa-style): low-risk pays frictionlessly under an SCA exemption;
high-risk must clear an active screen-flash liveness challenge first. Pass a
prior ``flash_session`` that verified successfully to satisfy the step-up."""
if state.unified is None:
raise HTTPException(status_code=503, detail="unified model not loaded")
authorize(body["user_id"], request)
rate_limit(request, "pay", capacity=8, refill_per_sec=0.3)
uid = body["user_id"]
state.ensure_user_loaded(uid) # read-through if enrolled on another replica
amount = float(body.get("amount", 0))
merchant = body.get("merchant", "Merchant")
samples = _unified_samples(body)
bio = state.unified.authenticate(uid, **samples)
biometric_ok = bool(bio.get("success", False))
# deepfake gate on the face modality
is_df = False
if body.get("face_image"):
df = state.deepfake_score(decode_image(body["face_image"]))
if df is not None and df >= state.deepfake_threshold:
is_df = True; biometric_ok = False; bio["reason"] = "Deepfake/injection detected"
# Extra step-up signals.
liveness_ok = bool(body.get("liveness_ok", False))
device_verified = bool(body.get("device_verified", False))
oob_confirmed = state.notify.is_approved(body.get("confirmation_id"), now=time.time())
# Location signal (graduated — never a hard block for a cardless traveller).
geo = state.geo.assess(uid, body.get("lat"), body.get("lon"),
country=body.get("country"), now=time.time())
recent = len(state.wallet.transactions(uid))
risk = state.risk.assess(
amount=amount, biometric_ok=biometric_ok,
confidence=float(bio.get("confidence") or bio.get("similarity") or 0.0),
modalities=bio.get("modalities"), recent_count=recent, deepfake=is_df,
liveness_ok=liveness_ok, device_verified=device_verified,
oob_confirmed=oob_confirmed, geo=geo)
record_payment(risk["decision"])
bio_view = {"success": biometric_ok, "similarity": bio.get("similarity"),
"modalities": bio.get("modalities"), "reason": bio.get("reason")}
# High-risk payments must clear a step-up: EITHER an active-liveness challenge,
# the device's built-in biometric (Touch ID / fingerprint), OR an out-of-band
# confirmation. Offer all three so the client can pick.
if risk["decision"] == "step_up":
conf = state.notify.send_payment_confirmation(uid, amount, merchant, now=time.time())
return {"success": False, "decision": "step_up", "risk": risk,
"challenge_required": True,
"step_up_options": ["active_liveness", "device_biometric", "out_of_band"],
"confirmation": {"confirmation_id": conf["confirmation_id"],
"channels": conf["channels"],
"dynamic_linked": conf["dynamic_linked"]},
"geo": geo,
"reason": "Extra verification required — " + ", ".join(risk["reasons"]),
"biometric": bio_view}
if risk["decision"] == "decline":
return {"success": False, "decision": "decline", "risk": risk, "geo": geo,
"reason": risk["reasons"][0] if risk["reasons"] else "declined",
"biometric": bio_view}
# PSD2 SCA possession factor. A real possession proof is a registered device
# passkey, a verified device-biometric assertion, or an out-of-band confirmation.
# Submitting biometrics alone is NOT possession — only allowed as a fallback when
# strict SCA is off (demo). Production sets AMANPAY_STRICT_SCA=1.
import os as _os
_strict = (_os.getenv("AMANPAY_STRICT_SCA", "0").strip().lower()
not in ("", "0", "false", "no"))
possession_ok = (state.passkeys.is_registered(uid) or device_verified
or oob_confirmed or (bool(samples) and not _strict))
r = state.wallet.authorize_payment(
uid, body["account_id"], amount, merchant, biometric_ok, possession_ok,
now=time.time())
r["decision"] = risk["decision"]
r["risk"] = risk
r["geo"] = geo
r["biometric"] = bio_view
# Out-of-band receipt/confirmation on the user's preferred channel(s).
if r.get("success"):
r["confirmation"] = state.notify.send_payment_confirmation(
uid, amount, merchant, now=time.time())
state.persist() # persist the new balance / transaction
state.audit(uid, "pay", {"amount": amount, "merchant": merchant,
"decision": risk["decision"], "risk": risk["risk"],
"signature": r.get("transaction", {}).get("signature")})
return r
# ---- One-click demo seed (default example biometrics) ----
_EXAMPLES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "examples")
def _example_b64(name: str) -> str | None:
path = os.path.join(_EXAMPLES_DIR, name)
if not os.path.exists(path):
return None
mt = "audio/wav" if name.endswith(".wav") else \
"image/png" if name.endswith(".png") else "image/jpeg"
with open(path, "rb") as fh:
return f"data:{mt};base64," + base64.b64encode(fh.read()).decode()
@router.post("/demo/seed")
def demo_seed(body: dict = Body(...)) -> dict:
"""Seed a ready-to-pay demo: enroll the tri-modal `alice` identity from the
bundled example samples, record consent, link a token-backed account, and
return the *authenticate* samples so the client can run a payment immediately."""
if state.unified is None:
raise HTTPException(status_code=503, detail="unified model not loaded")
uid = body.get("user_id", "demo")
enroll = {k: _example_b64(v) for k, v in {
"face_image": "alice_face_enroll.jpg",
"fingerprint_image": "alice_fp_enroll.png",
"voice_audio": "alice_voice_enroll.wav"}.items()}
if not any(enroll.values()):
raise HTTPException(status_code=404, detail="example samples not found")
en = state.unified.enroll(uid, **_unified_samples(enroll))
state.wallet.record_consent(uid, "biometric payments (demo)", "1 year", now=time.time())
acc = state.wallet.link_account(uid, "Chase", "checking", "4242424242424242",
now=time.time()).get("account")
auth = {k: _example_b64(v) for k, v in {
"face_image": "alice_face_auth.jpg",
"fingerprint_image": "alice_fp_auth.png",
"voice_audio": "alice_voice_auth.wav"}.items()}
return {"success": bool(en.get("success")), "user_id": uid,
"modalities": en.get("modalities"), "account": acc,
"token": mint_token(uid),
"samples": {k: v for k, v in auth.items() if v}}
# ---- Non-custodial Payment Core (provider-independent, Saudi-first) ----
@router.post("/payments")
def payments_create(request: Request, body: dict = Body(...)) -> dict:
"""Initiate a customer-bank -> merchant payment via a licensed provider (mock for
now). Non-custodial: AmanPay never holds funds. Requires an Idempotency-Key."""
from amanpay.payments import PaymentError
authorize(body["user_id"], request)
rate_limit(request, "payments", capacity=8, refill_per_sec=0.3)
idem = request.headers.get("idempotency-key") or body.get("idempotency_key")
if not idem:
raise HTTPException(status_code=400, detail="Idempotency-Key required")
try:
p = state.payments.create_payment(
user_id=body["user_id"], amount_minor=int(body["amount_minor"]),
country=body.get("country"), currency=body.get("currency"),
merchant_id=body["merchant_id"], payee_iban=body["payee_iban"],
consent=body.get("consent") or {}, idempotency_key=idem,
correlation_id=request.headers.get("x-correlation-id"),
description=body.get("description", ""), now=time.time())
except PaymentError as exc:
raise HTTPException(status_code=422, detail=str(exc))
return p.view()
@router.get("/payments/providers")
def payments_providers() -> dict:
"""Provider routing + capabilities per market, and the Saudi-first defaults."""
from amanpay.payments.config import (DEFAULT_COUNTRY, DEFAULT_CURRENCY,
DEFAULT_TIMEZONE, LOCALES)
from amanpay.payments.registry import get_provider, provider_name_for
routing = {}
for cc in ["SA", "AE", "GB", "EU", "US"]:
try:
routing[cc] = {"provider": provider_name_for(cc),
"capabilities": get_provider(cc).capabilities().__dict__}
except NotImplementedError as exc:
routing[cc] = {"provider": provider_name_for(cc), "status": str(exc)}
return {"default": {"country": DEFAULT_COUNTRY, "currency": DEFAULT_CURRENCY,
"timezone": DEFAULT_TIMEZONE, "locales": LOCALES},
"non_custodial": True, "routing": routing}
@router.get("/payments/{payment_id}")
def payments_get(payment_id: str, request: Request) -> dict:
from amanpay.payments import PaymentError
try:
p = state.payments.get_status(payment_id, now=time.time())
except PaymentError:
raise HTTPException(status_code=404, detail="payment not found")
authorize(p.user_id, request)
return p.view()
@router.post("/payments/{payment_id}/cancel")
def payments_cancel(payment_id: str, request: Request) -> dict:
from amanpay.payments import PaymentError
p = state.payments.store.get(payment_id)
if p is None:
raise HTTPException(status_code=404, detail="payment not found")
authorize(p.user_id, request)
try:
return state.payments.cancel(payment_id, now=time.time()).view()
except PaymentError as exc:
raise HTTPException(status_code=422, detail=str(exc))
@router.post("/payments/{payment_id}/refund")
def payments_refund(payment_id: str, request: Request, body: dict = Body(...)) -> dict:
from amanpay.payments import PaymentError
p = state.payments.store.get(payment_id)
if p is None:
raise HTTPException(status_code=404, detail="payment not found")
authorize(p.user_id, request)
idem = (request.headers.get("idempotency-key") or body.get("idempotency_key")
or ("rf_" + str(int(time.time() * 1000))))
try:
return state.payments.refund(payment_id, int(body["amount_minor"]), idem,
body.get("reason", ""), now=time.time()).view()
except PaymentError as exc:
raise HTTPException(status_code=422, detail=str(exc))
@router.post("/payments/{payment_id}/mock-advance")
def payments_mock_advance(payment_id: str, request: Request, body: dict = Body(...)) -> dict:
"""DEV/mock only: simulate the provider advancing this payment by delivering a
*signed* webhook internally (so it exercises the real signature/idempotency/ordering
path). Rejected for any real provider — never a production settlement path."""
import secrets as _secrets
p = state.payments.store.get(payment_id)
if p is None:
raise HTTPException(status_code=404, detail="payment not found")
authorize(p.user_id, request)
if p.provider != "mock":
raise HTTPException(status_code=403, detail="mock-advance is only for the mock provider")
from amanpay.payments.registry import get_provider
prov = get_provider(p.country)
payload = {"event_id": "mev_" + _secrets.token_hex(6), "provider_ref": p.provider_ref,
"raw_status": body.get("raw_status", "SETTLED"),
"sequence": int(time.time() * 1000)}
headers, raw = prov.sign_webhook(payload, now=time.time())
return state.payments.handle_webhook(p.country, headers, raw, now=time.time())
@router.post("/payments/webhooks/{provider}")
async def payments_webhook(provider: str, request: Request) -> dict:
"""Provider webhook receiver — settlement evidence comes ONLY from signed webhooks,
never from redirect/callback query params. Unauthenticated by design (verified by
the provider's webhook signature inside handle_webhook)."""
raw = await request.body()
headers = {k.lower(): v for k, v in request.headers.items()}
return state.payments.handle_webhook(provider, headers, raw, now=time.time())
@router.get("/report-card")
def report_card(refresh: bool = False,
model: AmanPayAuthenticator = Depends(get_model)) -> dict:
"""Standards & performance conformance summary (recognition, PAD/FIDO,
ISO/IEC 24745 template protection, deployment). Cached; ``?refresh=true`` rebuilds."""
if state.report_card is None or refresh:
from amanpay.evaluation.report_card import build_report_card
cfg = model.config
state.report_card = build_report_card(
fusion_dim=cfg.fusion.output_dim, protection_bits=cfg.auth.protection_bits)
return state.report_card
@router.get("/users", response_model=UserListResponse)
def list_users(request: Request,
model: AmanPayAuthenticator = Depends(get_model)) -> UserListResponse:
# Enumeration is admin-only when auth is enforced (needs X-Admin-Token).
if require_auth():
if request.headers.get("x-admin-token") != os.getenv("AMANPAY_ADMIN_TOKEN", ""):
raise HTTPException(status_code=403, detail="admin only")
return UserListResponse(users=list(model.enrolled_templates.keys()))
@router.delete("/users/{user_id}")
def delete_user(user_id: str, request: Request) -> dict:
"""Right-to-erasure: cascade-delete the user across memory + datastore."""
authorize(user_id, request) # self-delete only (or no-op when auth off)
ok = state.erase(user_id)
state.audit(user_id, "erasure", {"cascade": True})
return {"success": True, "user_id": user_id, "erased_in_store": ok,
"message": "User and all linked data erased"}
@router.get("/wallet/spc-key")
def wallet_spc_key() -> dict:
"""Publish the server's Secure-Payment-Confirmation Ed25519 verification key so
a merchant/auditor can independently verify transaction signatures."""
from amanpay.banking.wallet import spc_public_key_hex
return {"alg": "Ed25519", "public_key_hex": spc_public_key_hex()}