From 75f46beca42f6d230d94b80e109420e363bd38e5 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:22:54 +0100 Subject: [PATCH 01/21] fix: repair Vercel routing configuration --- vercel.json | 42 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/vercel.json b/vercel.json index 63221a7..82af315 100644 --- a/vercel.json +++ b/vercel.json @@ -2,48 +2,38 @@ "version": 2, "builds": [ { - "src": "api/converter_service/main.py", - "use": "@vercel/python", - "config": { "maxLambdaSize": "15mb" } + "src": "api/index.py", + "use": "@vercel/python" }, { - "src": "api/card_platform_service/main.py", - "use": "@vercel/python", - "config": { "maxLambdaSize": "15mb" } - } - ], - "routes": [ - { - "src": "/api/converter/(.*)", - "dest": "/api/converter_service/main.py" + "src": "api/card_service.py", + "use": "@vercel/python" }, { - "src": "/api/platform/(.*)", - "dest": "/api/card_platform_service/main.py" - "src": "api/*.py", + "src": "api/converter_service.py", "use": "@vercel/python" } ], - "rewrites": [ + "routes": [ { - "source": "/api/v1/(.*)", - "destination": "/api/card_service.py" + "src": "/api/v1/(.*)", + "dest": "/api/card_service.py" }, { - "source": "/webhook/(.*)", - "destination": "/api/converter_service.py" + "src": "/webhook/(.*)", + "dest": "/api/converter_service.py" }, { - "source": "/internal/(.*)", - "destination": "/api/converter_service.py" + "src": "/internal/(.*)", + "dest": "/api/converter_service.py" }, { - "source": "/health", - "destination": "/api/index.py" + "src": "/health", + "dest": "/api/index.py" }, { - "source": "/", - "destination": "/api/index.py" + "src": "/", + "dest": "/api/index.py" } ] } From 1e5f9f388ec1e088a88af538d456612b7a7f5d77 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:23:23 +0100 Subject: [PATCH 02/21] feat: add shared Nexus service package --- common/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 common/__init__.py diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..db9eaa1 --- /dev/null +++ b/common/__init__.py @@ -0,0 +1 @@ +"""Shared security and configuration helpers for Nexus services.""" From 56821b5aebb67e09a67e514a18bb9c60c359d00f Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:23:38 +0100 Subject: [PATCH 03/21] feat: authenticate internal Nexus service requests --- common/security.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 common/security.py diff --git a/common/security.py b/common/security.py new file mode 100644 index 0000000..a04a36b --- /dev/null +++ b/common/security.py @@ -0,0 +1,48 @@ +"""Cryptographic helpers for service-to-service request authentication.""" + +from __future__ import annotations + +import hashlib +import hmac +import time +from typing import Final + +INTERNAL_TIMESTAMP_HEADER: Final[str] = "X-Internal-Timestamp" +INTERNAL_SIGNATURE_HEADER: Final[str] = "X-Internal-Signature" + + +def sign_internal_request(secret: str, payload: bytes, timestamp: int | None = None) -> tuple[str, str]: + """Return timestamp and HMAC-SHA256 signature for an exact request body.""" + if not secret: + raise ValueError("INTERNAL_SERVICE_SECRET is required") + + timestamp_value = int(time.time()) if timestamp is None else int(timestamp) + signed_payload = str(timestamp_value).encode("ascii") + b"." + payload + signature = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest() + return str(timestamp_value), signature + + +def verify_internal_request( + secret: str, + payload: bytes, + timestamp_header: str | None, + signature_header: str | None, + *, + tolerance_seconds: int = 300, + now: int | None = None, +) -> bool: + """Verify signature and reject stale or future-dated requests.""" + if not secret or not timestamp_header or not signature_header: + return False + + try: + timestamp_value = int(timestamp_header) + except (TypeError, ValueError): + return False + + current_time = int(time.time()) if now is None else int(now) + if abs(current_time - timestamp_value) > tolerance_seconds: + return False + + _, expected_signature = sign_internal_request(secret, payload, timestamp_value) + return hmac.compare_digest(expected_signature, signature_header.strip()) From 259bee628d633a32fbb0b141af86e5739e677632 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:23:53 +0100 Subject: [PATCH 04/21] feat: add persistent conversion idempotency store --- converter_service/idempotency.py | 157 +++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 converter_service/idempotency.py diff --git a/converter_service/idempotency.py b/converter_service/idempotency.py new file mode 100644 index 0000000..86022e4 --- /dev/null +++ b/converter_service/idempotency.py @@ -0,0 +1,157 @@ +"""Persistent idempotency storage for conversion and webhook processing.""" + +from __future__ import annotations + +import json +import os +import threading +from typing import Any + +import psycopg2 + + +class IdempotencyStore: + """Claims transaction keys atomically and stores completed responses. + + PostgreSQL is mandatory when APP_ENV=production. An in-memory fallback is + intentionally limited to development and automated tests. + """ + + def __init__(self) -> None: + self.database_url = os.getenv("DATABASE_URL", "").strip() + self.app_env = os.getenv("APP_ENV", "development").strip().lower() + self._memory: dict[str, dict[str, Any]] = {} + self._lock = threading.Lock() + self._schema_ready = False + + def _require_safe_backend(self) -> None: + if not self.database_url and self.app_env == "production": + raise RuntimeError("DATABASE_URL is required for production idempotency") + + def _connect(self): + if not self.database_url: + return None + return psycopg2.connect(self.database_url) + + def _ensure_schema(self) -> None: + if not self.database_url or self._schema_ready: + return + + with self._lock: + if self._schema_ready: + return + connection = self._connect() + try: + with connection: + with connection.cursor() as cursor: + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS nexus_idempotency_keys ( + idempotency_key TEXT PRIMARY KEY, + status TEXT NOT NULL, + response_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + self._schema_ready = True + finally: + connection.close() + + def begin(self, key: str) -> bool: + """Atomically claim a new key. Returns False for an existing key.""" + self._require_safe_backend() + if not self.database_url: + with self._lock: + if key in self._memory: + return False + self._memory[key] = {"status": "processing", "response": None} + return True + + self._ensure_schema() + connection = self._connect() + try: + with connection: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO nexus_idempotency_keys (idempotency_key, status) + VALUES (%s, 'processing') + ON CONFLICT (idempotency_key) DO NOTHING + RETURNING idempotency_key + """, + (key,), + ) + return cursor.fetchone() is not None + finally: + connection.close() + + def get_completed_response(self, key: str) -> dict[str, Any] | None: + self._require_safe_backend() + if not self.database_url: + with self._lock: + record = self._memory.get(key) + if record and record["status"] == "completed": + return record["response"] + return None + + self._ensure_schema() + connection = self._connect() + try: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT response_json + FROM nexus_idempotency_keys + WHERE idempotency_key = %s AND status = 'completed' + """, + (key,), + ) + row = cursor.fetchone() + return row[0] if row else None + finally: + connection.close() + + def complete(self, key: str, response: dict[str, Any]) -> None: + self._require_safe_backend() + if not self.database_url: + with self._lock: + self._memory[key] = {"status": "completed", "response": response} + return + + self._ensure_schema() + connection = self._connect() + try: + with connection: + with connection.cursor() as cursor: + cursor.execute( + """ + UPDATE nexus_idempotency_keys + SET status = 'completed', response_json = %s::jsonb, updated_at = NOW() + WHERE idempotency_key = %s + """, + (json.dumps(response, default=str), key), + ) + finally: + connection.close() + + def release_failed(self, key: str) -> None: + """Release a failed claim so a provider retry can safely try again.""" + self._require_safe_backend() + if not self.database_url: + with self._lock: + self._memory.pop(key, None) + return + + self._ensure_schema() + connection = self._connect() + try: + with connection: + with connection.cursor() as cursor: + cursor.execute( + "DELETE FROM nexus_idempotency_keys WHERE idempotency_key = %s AND status = 'processing'", + (key,), + ) + finally: + connection.close() From 770f0a1327c0d26c366c4adc5216cacbb115648a Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:24:11 +0100 Subject: [PATCH 05/21] feat: make BTC conversion sandbox-first and fail closed --- converter_service/logic.py | 201 ++++++++++++++++++++----------------- 1 file changed, 110 insertions(+), 91 deletions(-) diff --git a/converter_service/logic.py b/converter_service/logic.py index 6b2d3dd..704881f 100644 --- a/converter_service/logic.py +++ b/converter_service/logic.py @@ -1,99 +1,118 @@ -import os -import hmac +"""Core conversion policy and sandbox provider adapter. + +Live money movement is deliberately fail-closed until a verified provider +contract, production credentials, and owner approval are configured. +""" + +from __future__ import annotations + import hashlib +import hmac import json -import time -import requests -from typing import Dict, Any -from datetime import datetime +import os +import uuid +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation, ROUND_DOWN +from typing import Any -# Load environment variables (Agent must ensure this runs in the building environment) from dotenv import load_dotenv + load_dotenv() -# Configuration from .env -WEBHOOK_SECRET_RAW = os.environ.get("STRIGA_WEBHOOK_SECRET", "") -WEBHOOK_SECRET = WEBHOOK_SECRET_RAW.encode('utf-8') if WEBHOOK_SECRET_RAW else b"" -API_KEY = os.environ.get("STRIGA_API_KEY") -API_BASE_URL = os.environ.get("STRIGA_API_BASE_URL") class ConversionLogic: - """Handles core security checks and the external conversion API call.""" - - def __init__(self): - # NOTE: In a real environment, this should connect to Redis or PostgreSQL - # for a persistent idempotency check. We use a set for demonstration. - self.processed_transactions = set() - print("ConversionLogic initialized. Connects to Striga API.") - - # ---------------------------------------------------------------------- - # CRITICAL SECURITY - # ---------------------------------------------------------------------- - def validate_webhook_signature(self, payload_raw: bytes, signature_header: str) -> bool: - """[SECURITY] Verifies the HMAC-SHA256 signature against the payload.""" - - # NOTE: Striga often uses a header format like 't=timestamp,v1=signature' - # This implementation assumes the header is just the signature for simplicity. - # AGENT MUST ADJUST based on Striga's exact specification. - - if not WEBHOOK_SECRET: - return False - - signature_value = signature_header - if "," in signature_header: - parts = dict( - item.split("=", 1) - for item in signature_header.split(",") - if "=" in item - ) - signature_value = parts.get("v1", "") - - computed_signature = hmac.new(WEBHOOK_SECRET, payload_raw, hashlib.sha256).hexdigest() - - # hmac.compare_digest prevents timing attacks - return hmac.compare_digest(computed_signature, signature_value) - - def is_already_processed(self, transaction_id: str) -> bool: - """[IDEMPOTENCY] Prevents double-processing of the same webhook.""" - if transaction_id in self.processed_transactions: - return True - self.processed_transactions.add(transaction_id) - return False - - # ---------------------------------------------------------------------- - # CORE CONVERSION FUNCTION - # ---------------------------------------------------------------------- - def execute_conversion_and_payout(self, fiat_amount: float, fiat_currency: str, user_id: str) -> Dict[str, Any]: - """ - [CORE LOGIC] Calls the external API to convert fiat to BTC and initiate payout. - - This is where the API call to Striga's conversion endpoint would happen. - """ - - print(f"Executing conversion for User {user_id}: {fiat_currency} {fiat_amount}") - - if fiat_amount <= 0: - raise ValueError("Transaction amount must be greater than zero.") - - normalized_currency = fiat_currency.upper() - supported_currencies = {"USD": 70000.0, "EUR": 76000.0, "GBP": 82000.0} - if normalized_currency not in supported_currencies: - raise ValueError(f"Unsupported fiat currency: {fiat_currency}") - - # --- MOCKING API CALL for safe testing --- - # AGENT ACTION: Replace this block with actual requests.post(...) call. - time.sleep(1) # Simulate network latency - if fiat_amount > 10000: - raise ValueError("Transaction amount exceeds mock limit.") - - exchange_rate = supported_currencies[normalized_currency] - btc_amount = round(fiat_amount / exchange_rate, 8) - - return { - "btc_amount_sent": btc_amount, - "satoshis_sent": int(btc_amount * 100_000_000), - "exchange_rate_used": exchange_rate, - "fiat_currency": normalized_currency, - "success_time": datetime.now().isoformat() - } - # --- END MOCKING --- + """Validate requests and execute fiat-to-BTC conversions in sandbox mode.""" + + def __init__(self) -> None: + self.webhook_secret = os.getenv("STRIGA_WEBHOOK_SECRET", "").strip() + self.payments_mode = os.getenv("PAYMENTS_MODE", "sandbox").strip().lower() + self.allow_live_payments = os.getenv("ALLOW_LIVE_PAYMENTS", "false").strip().lower() == "true" + self.aml_limit = Decimal(os.getenv("AML_SINGLE_TRANSACTION_LIMIT", "10000")) + self.supported_currencies = { + item.strip().upper() + for item in os.getenv("SUPPORTED_FIAT_CURRENCIES", "USD,EUR,GBP").split(",") + if item.strip() + } + self.sandbox_rates = self._load_sandbox_rates() + + @staticmethod + def _normalize_signature(signature_header: str) -> str: + signature = signature_header.strip() + if signature.startswith("sha256="): + return signature.split("=", 1)[1] + if "," in signature: + parts = { + item.split("=", 1)[0].strip(): item.split("=", 1)[1].strip() + for item in signature.split(",") + if "=" in item + } + return parts.get("v1", "") + return signature + + def validate_webhook_signature(self, payload_raw: bytes, signature_header: str) -> bool: + """Verify a generic HMAC-SHA256 webhook signature in constant time.""" + if not self.webhook_secret or not signature_header: + return False + expected = hmac.new( + self.webhook_secret.encode("utf-8"), payload_raw, hashlib.sha256 + ).hexdigest() + supplied = self._normalize_signature(signature_header) + return bool(supplied) and hmac.compare_digest(expected, supplied) + + def _load_sandbox_rates(self) -> dict[str, Decimal]: + default_rates = {"USD": "70000", "EUR": "76000", "GBP": "82000"} + raw = os.getenv("SANDBOX_BTC_RATES_JSON", json.dumps(default_rates)) + try: + parsed = json.loads(raw) + rates = {str(key).upper(): Decimal(str(value)) for key, value in parsed.items()} + except (json.JSONDecodeError, InvalidOperation, AttributeError, TypeError) as exc: + raise RuntimeError("SANDBOX_BTC_RATES_JSON is invalid") from exc + + if not rates or any(rate <= 0 for rate in rates.values()): + raise RuntimeError("Sandbox BTC rates must be positive") + return rates + + def execute_conversion_and_payout( + self, fiat_amount: Decimal | float | str, fiat_currency: str, user_id: str + ) -> dict[str, Any]: + """Execute a deterministic sandbox conversion or fail closed in live mode.""" + try: + amount = Decimal(str(fiat_amount)) + except InvalidOperation as exc: + raise ValueError("Invalid fiat amount") from exc + + if amount <= 0: + raise ValueError("Transaction amount must be greater than zero") + if amount > self.aml_limit: + raise ValueError("Transaction amount exceeds the configured AML limit") + + currency = fiat_currency.strip().upper() + if currency not in self.supported_currencies: + raise ValueError("Unsupported fiat currency") + + if self.payments_mode != "sandbox": + if not self.allow_live_payments: + raise RuntimeError("Live payments are disabled") + raise RuntimeError( + "Live provider adapter is not configured; keep PAYMENTS_MODE=sandbox" + ) + + rate = self.sandbox_rates.get(currency) + if rate is None: + raise ValueError("No sandbox BTC rate configured for this currency") + + btc_amount = (amount / rate).quantize(Decimal("0.00000001"), rounding=ROUND_DOWN) + satoshis = int(btc_amount * Decimal("100000000")) + + return { + "status": "simulated", + "payment_mode": "sandbox", + "provider_reference": f"sandbox-{uuid.uuid4()}", + "user_id": user_id, + "fiat_amount": format(amount, "f"), + "fiat_currency": currency, + "btc_amount_sent": format(btc_amount, "f"), + "satoshis_sent": satoshis, + "exchange_rate_used": format(rate, "f"), + "success_time": datetime.now(timezone.utc).isoformat(), + } From ddb85b612f2710661a870fb0024b22934b37c08b Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:24:34 +0100 Subject: [PATCH 06/21] feat: secure and persist converter transaction processing --- converter_service/main.py | 242 +++++++++++++++++++++++--------------- 1 file changed, 150 insertions(+), 92 deletions(-) diff --git a/converter_service/main.py b/converter_service/main.py index 9c1c463..b5fe462 100644 --- a/converter_service/main.py +++ b/converter_service/main.py @@ -1,117 +1,175 @@ +"""Nexus fiat-to-BTC converter API. + +All money movement defaults to deterministic sandbox behavior. Internal calls +must be signed, webhook payloads must be HMAC verified, and every transaction +is protected by an idempotency claim. +""" + +from __future__ import annotations + import json -import time import logging -from fastapi import FastAPI, Request, HTTPException, Header -from pydantic import BaseModel +import os +import time +from decimal import Decimal +from typing import Any +from uuid import UUID + +from fastapi import FastAPI, Header, HTTPException, Request +from pydantic import BaseModel, Field, ValidationError + +from common.security import verify_internal_request +from .idempotency import IdempotencyStore from .logic import ConversionLogic -# Structured JSON logging logging.basicConfig( level=logging.INFO, - format='{"timestamp":"%(asctime)s","service":"converter","level":"%(levelname)s","message":"%(message)s"}' + format='{"timestamp":"%(asctime)s","service":"converter","level":"%(levelname)s","message":"%(message)s"}', ) logger = logging.getLogger(__name__) -app = FastAPI(title="Fiat-to-BTC Converter Service", description="Project 1: Secure Webhook Handler.") +app = FastAPI( + title="Nexus Fiat-to-BTC Converter Service", + description="Authenticated, idempotent, sandbox-first conversion service.", + version="2.0.0", +) converter_logic = ConversionLogic() +idempotency_store = IdempotencyStore() + + +class InternalFundTransferRequest(BaseModel): + user_id: str = Field(min_length=1, max_length=128) + fiat_amount: Decimal = Field(gt=0, max_digits=18, decimal_places=2) + fiat_currency: str = Field(min_length=3, max_length=3) + trace_id: UUID + + +class FiatReceivedWebhook(BaseModel): + transaction_id: str = Field(min_length=1, max_length=200) + amount: Decimal = Field(gt=0, max_digits=18, decimal_places=2) + currency: str = Field(min_length=3, max_length=3) + user_id: str = Field(min_length=1, max_length=128) + + +def _process_transaction( + transaction_key: str, + *, + amount: Decimal, + currency: str, + user_id: str, +) -> dict[str, Any]: + claimed = idempotency_store.begin(transaction_key) + if not claimed: + completed = idempotency_store.get_completed_response(transaction_key) + if completed is not None: + return {"status": "success", "idempotent_replay": True, "data": completed} + raise HTTPException(status_code=409, detail="Transaction is already processing") + + try: + result = converter_logic.execute_conversion_and_payout( + fiat_amount=amount, + fiat_currency=currency, + user_id=user_id, + ) + idempotency_store.complete(transaction_key, result) + return {"status": "success", "idempotent_replay": False, "data": result} + except ValueError as exc: + idempotency_store.release_failed(transaction_key) + raise HTTPException(status_code=422, detail=str(exc)) from exc + except RuntimeError as exc: + idempotency_store.release_failed(transaction_key) + logger.error("Conversion configuration blocked transaction %s: %s", transaction_key, exc) + raise HTTPException(status_code=503, detail="Conversion service is not configured for this operation") from exc + except Exception as exc: + idempotency_store.release_failed(transaction_key) + logger.exception("Conversion failed for transaction %s", transaction_key) + raise HTTPException(status_code=500, detail="Conversion processing failed") from exc @app.get("/health") -def health_check(): - """Health check endpoint for Kubernetes probes.""" - return {"status": "healthy", "service": "converter-service", "timestamp": time.time()} +def health_check() -> dict[str, Any]: + return { + "status": "healthy", + "service": "converter-service", + "payment_mode": converter_logic.payments_mode, + "timestamp": time.time(), + } @app.get("/ready") -def readiness_check(): - """Readiness check endpoint.""" - return {"status": "ready"} +def readiness_check() -> dict[str, Any]: + missing: list[str] = [] + if not os.getenv("INTERNAL_SERVICE_SECRET", "").strip(): + missing.append("INTERNAL_SERVICE_SECRET") + if not converter_logic.webhook_secret: + missing.append("STRIGA_WEBHOOK_SECRET") + if os.getenv("APP_ENV", "development").lower() == "production" and not os.getenv("DATABASE_URL", "").strip(): + missing.append("DATABASE_URL") + + if missing: + raise HTTPException( + status_code=503, + detail={"status": "not_ready", "missing_configuration": missing}, + ) + return {"status": "ready", "payment_mode": converter_logic.payments_mode} -class InternalFundTransferRequest(BaseModel): - user_id: str - fiat_amount: float - fiat_currency: str - trace_id: str @app.post("/internal/transfer_funds") -async def internal_transfer_funds(request: InternalFundTransferRequest): - """ - Internal endpoint to execute the conversion logic, called by the Card Platform Service. - """ - trace_id = request.trace_id - logger.info(f"Internal fund transfer received for trace_id: {trace_id}") - - # Use a combination of a prefix and trace_id for a unique, traceable transaction ID - transaction_id = f"internal-tx-{trace_id}" - - # 2. Idempotency Check - if converter_logic.is_already_processed(transaction_id): - logger.warning(f"Transaction already processed (Idempotent success) for trace_id: {trace_id}") - return {"status": "success", "message": "Transaction already processed (Idempotent success)"} - - # 3. Execute Conversion +async def internal_transfer_funds( + request: Request, + x_internal_timestamp: str | None = Header(default=None, alias="X-Internal-Timestamp"), + x_internal_signature: str | None = Header(default=None, alias="X-Internal-Signature"), +) -> dict[str, Any]: + payload_raw = await request.body() + secret = os.getenv("INTERNAL_SERVICE_SECRET", "").strip() + if not verify_internal_request( + secret, + payload_raw, + x_internal_timestamp, + x_internal_signature, + ): + raise HTTPException(status_code=401, detail="Invalid internal request signature") + try: - result = converter_logic.execute_conversion_and_payout( - fiat_amount=request.fiat_amount, - fiat_currency=request.fiat_currency, - user_id=request.user_id - ) - logger.info(f"[AUDIT LOG] Fiat-to-BTC Success: ID={transaction_id}, BTC Sent={result['btc_amount_sent']}, trace_id={trace_id}") - return {"status": "success", "data": result} + payload = InternalFundTransferRequest(**json.loads(payload_raw)) + except (json.JSONDecodeError, UnicodeDecodeError, ValidationError) as exc: + raise HTTPException(status_code=400, detail="Invalid transfer request") from exc + + trace_id = str(payload.trace_id) + logger.info("Authenticated internal transfer received trace_id=%s", trace_id) + return _process_transaction( + f"internal:{trace_id}", + amount=payload.fiat_amount, + currency=payload.fiat_currency, + user_id=payload.user_id, + ) - except Exception as e: - logger.critical(f"[CRITICAL ERROR] Conversion failed for TX {transaction_id} (trace_id: {trace_id}): {e}") - raise HTTPException(status_code=500, detail=f"Conversion processing failed: {e}") @app.post("/webhook/fiat_received") -async def fiat_received_webhook( - request: Request, - # NOTE: Striga often uses X-Striga-Signature, adjust header name as needed. - x_signature: str = Header(..., alias='X-Signature') -): - """ - Receives webhook notification for fiat payment receipt, validates it, and executes conversion. - """ - content_type = request.headers.get("content-type", "") - if "application/json" not in content_type.lower(): - raise HTTPException(status_code=415, detail="Unsupported media type. Binary files are not supported") - - payload_raw = await request.body() - try: - payload = json.loads(payload_raw) - except (UnicodeDecodeError, json.JSONDecodeError): - raise HTTPException(status_code=400, detail="Invalid JSON payload. Binary files are not supported") - - # 1. Signature Validation [CRITICAL SECURITY] - if not converter_logic.validate_webhook_signature(payload_raw, x_signature): - logger.warning("Invalid webhook signature received.") - raise HTTPException(status_code=403, detail="Invalid webhook signature") - - # Extract required fields (adjust keys based on Striga payload) - transaction_id = payload.get("transaction_id") - fiat_amount = payload.get("amount") - fiat_currency = payload.get("currency") - user_id = payload.get("user_id") - - if not all([transaction_id, fiat_amount, fiat_currency, user_id]): - raise HTTPException(status_code=400, detail="Missing required transaction data") - - # 2. Idempotency Check - if converter_logic.is_already_processed(transaction_id): - logger.warning(f"Transaction {transaction_id} already processed (Idempotent success).") - return {"status": "success", "message": "Transaction already processed (Idempotent success)"} - - # 3. Execute Conversion - try: - result = converter_logic.execute_conversion_and_payout( - fiat_amount=fiat_amount, - fiat_currency=fiat_currency, - user_id=user_id +async def fiat_received_webhook(request: Request) -> dict[str, Any]: + content_type = request.headers.get("content-type", "") + if "application/json" not in content_type.lower(): + raise HTTPException(status_code=415, detail="Content-Type must be application/json") + + payload_raw = await request.body() + signature = ( + request.headers.get("x-striga-signature") + or request.headers.get("x-signature") + or "" ) - logger.info(f"[AUDIT LOG] Fiat-to-BTC Success: ID={transaction_id}, BTC Sent={result['btc_amount_sent']}") + if not converter_logic.validate_webhook_signature(payload_raw, signature): + logger.warning("Rejected webhook with invalid signature") + raise HTTPException(status_code=401, detail="Invalid webhook signature") - return {"status": "success", "data": result} - - except Exception as e: - logger.critical(f"[CRITICAL ERROR] Conversion failed for TX {transaction_id}: {e}") - raise HTTPException(status_code=500, detail=f"Conversion processing failed: {e}") + try: + payload = FiatReceivedWebhook(**json.loads(payload_raw)) + except (json.JSONDecodeError, UnicodeDecodeError, ValidationError) as exc: + raise HTTPException(status_code=400, detail="Invalid webhook payload") from exc + + logger.info("Verified fiat webhook transaction_id=%s", payload.transaction_id) + return _process_transaction( + f"webhook:{payload.transaction_id}", + amount=payload.amount, + currency=payload.currency, + user_id=payload.user_id, + ) From c69f117456e2e546c61540cf618566bbab78aa37 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:24:48 +0100 Subject: [PATCH 07/21] feat: harden KYC, card, and converter adapters --- card_platform_service/clients.py | 235 ++++++++++++++++++------------- 1 file changed, 134 insertions(+), 101 deletions(-) diff --git a/card_platform_service/clients.py b/card_platform_service/clients.py index 5af0b28..5993415 100644 --- a/card_platform_service/clients.py +++ b/card_platform_service/clients.py @@ -1,112 +1,145 @@ +"""Database and provider adapters for the Nexus card platform service.""" + +from __future__ import annotations + +import json +import logging import os -import requests -from typing import Dict, Any import uuid -import logging +from decimal import Decimal +from typing import Any + import psycopg2 +import requests +from dotenv import load_dotenv from psycopg2 import sql -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) +from common.security import ( + INTERNAL_SIGNATURE_HEADER, + INTERNAL_TIMESTAMP_HEADER, + sign_internal_request, +) -# Load environment variables -from dotenv import load_dotenv load_dotenv() +logger = logging.getLogger(__name__) -# Configuration -CONVERTER_INTERNAL_URL = os.environ.get("CONVERTER_INTERNAL_URL") -STRIGA_API_BASE_URL = os.environ.get("STRIGA_API_BASE_URL") # Still needed for Card API -DB_HOST = os.environ.get("DB_HOST") -DB_PORT = os.environ.get("DB_PORT", "5432") -DB_NAME = os.environ.get("DB_NAME") -DB_USER = os.environ.get("DB_USER") -DB_PASSWORD = os.environ.get("DB_PASSWORD") class CardPlatformLogic: - """Handles client-facing features: KYC, Card Issuance, and fund transfer.""" - - def __init__(self): - self.db_conn = None - try: - self.db_conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - dbname=DB_NAME, - user=DB_USER, - password=DB_PASSWORD - ) - logger.info("CardPlatformLogic initialized. Database connection established.") - except Exception as e: - logger.error(f"CardPlatformLogic initialized without database connectivity: {e}") - - def __del__(self): - if self.db_conn: - self.db_conn.close() - logger.info("Database connection closed.") - - def get_user_kyc_status(self, user_id: str) -> str: - """Retrieves the KYC status for a given user from the staging database.""" - if not self.db_conn: - return "KYC status check failed" - - try: - with self.db_conn.cursor() as cur: - cur.execute( - sql.SQL("SELECT kyc_tier FROM users WHERE user_id = %s"), - (user_id,) + """KYC policy, sandbox card issuance, and authenticated converter calls.""" + + def __init__(self) -> None: + self.database_url = os.getenv("DATABASE_URL", "").strip() + self.converter_internal_url = os.getenv("CONVERTER_INTERNAL_URL", "").rstrip("/") + self.internal_service_secret = os.getenv("INTERNAL_SERVICE_SECRET", "").strip() + self.card_mode = os.getenv("CARD_ISSUANCE_MODE", "sandbox").strip().lower() + self.allow_live_cards = os.getenv("ALLOW_LIVE_CARD_ISSUANCE", "false").strip().lower() == "true" + self.minimum_kyc_tier = int(os.getenv("KYC_MINIMUM_TIER", "3")) + self.request_timeout = float(os.getenv("INTERNAL_REQUEST_TIMEOUT_SECONDS", "8")) + + def _connect(self): + if self.database_url: + return psycopg2.connect(self.database_url) + + required = { + "host": os.getenv("DB_HOST", "").strip(), + "dbname": os.getenv("DB_NAME", "").strip(), + "user": os.getenv("DB_USER", "").strip(), + "password": os.getenv("DB_PASSWORD", "").strip(), + } + if not all(required.values()): + raise RuntimeError("Database configuration is incomplete") + + return psycopg2.connect( + host=required["host"], + port=os.getenv("DB_PORT", "5432"), + dbname=required["dbname"], + user=required["user"], + password=required["password"], + sslmode=os.getenv("DB_SSLMODE", "require"), + ) + + def check_database(self) -> bool: + try: + connection = self._connect() + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + return cursor.fetchone() == (1,) + finally: + connection.close() + except Exception: + logger.exception("Database readiness check failed") + return False + + def get_user_kyc_tier(self, user_id: str) -> int | None: + connection = self._connect() + try: + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("SELECT kyc_tier FROM users WHERE user_id = %s"), + (user_id,), + ) + row = cursor.fetchone() + return int(row[0]) if row else None + finally: + connection.close() + + def is_kyc_tier_approved(self, kyc_tier: int | None) -> bool: + return kyc_tier is not None and kyc_tier >= self.minimum_kyc_tier + + def issue_new_card(self, user_id: str, kyc_tier: int) -> dict[str, str]: + if not self.is_kyc_tier_approved(kyc_tier): + raise PermissionError("KYC tier is not approved for card issuance") + + if self.card_mode != "sandbox": + if not self.allow_live_cards: + raise RuntimeError("Live card issuance is disabled") + raise RuntimeError( + "Live card provider adapter is not configured; keep CARD_ISSUANCE_MODE=sandbox" ) - result = cur.fetchone() - if result: - return f"Tier {result[0]}" - else: - return "User not found" - except Exception as e: - logger.error(f"Database query failed for user {user_id}: {e}") - return "KYC status check failed" - - def issue_new_card(self, user_id: str, kyc_status: str) -> Dict[str, str]: - """Calls the external Striga API to issue a new card after KYC confirmation.""" - if not self.is_kyc_tier_approved(kyc_status): - return {"status": "error", "message": "KYC not approved for card issuance"} - - # --- MOCKING API CALL to Striga --- - # AGENT ACTION: Replace with actual requests.post(...) to /cards endpoint - card_id = f"card-{user_id}-123" - print(f"Striga API: Issued new CARD {card_id} for user {user_id}") - return {"status": "success", "card_id": card_id} - - @staticmethod - def is_kyc_tier_approved(kyc_status: str) -> bool: - """Current policy: Tier 3+ users are approved for card issuance.""" - if not kyc_status.startswith("Tier "): - return False - - try: - tier = int(kyc_status.split("Tier ", 1)[1]) - except ValueError: - return False - - return tier >= 3 - - def transfer_fiat_to_crypto(self, transaction_data: Dict[str, Any]) -> Dict[str, Any]: - """ - [DEPENDENCY] Calls the INTERNAL API of Project 1 (Converter Service) - to execute the conversion logic. - """ - trace_id = str(uuid.uuid4()) - transaction_data_with_trace = {**transaction_data, "trace_id": trace_id} - internal_url = f"{CONVERTER_INTERNAL_URL}/internal/transfer_funds" - - logger.info(f"Initiating fund transfer with trace_id: {trace_id}") - - # NOTE: This internal call should use a separate security header (e.g., JWT or shared secret) - # for service-to-service communication. - - try: - response = requests.post(internal_url, json=transaction_data_with_trace, timeout=5) - response.raise_for_status() - return response.json() - except requests.exceptions.RequestException as e: - logger.error(f"Internal Converter Service call failed for trace_id {trace_id}: {e}") - raise Exception("Failed to communicate with the core conversion service.") + + return { + "status": "simulated", + "card_mode": "sandbox", + "card_id": f"sandbox-card-{uuid.uuid4()}", + "user_id": user_id, + } + + def transfer_fiat_to_crypto( + self, + *, + user_id: str, + fiat_amount: Decimal, + fiat_currency: str, + ) -> dict[str, Any]: + if not self.converter_internal_url: + raise RuntimeError("CONVERTER_INTERNAL_URL is not configured") + if not self.internal_service_secret: + raise RuntimeError("INTERNAL_SERVICE_SECRET is not configured") + + trace_id = str(uuid.uuid4()) + payload = { + "user_id": user_id, + "fiat_amount": format(fiat_amount, "f"), + "fiat_currency": fiat_currency.upper(), + "trace_id": trace_id, + } + payload_raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + timestamp, signature = sign_internal_request( + self.internal_service_secret, + payload_raw, + ) + + response = requests.post( + f"{self.converter_internal_url}/internal/transfer_funds", + data=payload_raw, + headers={ + "Content-Type": "application/json", + INTERNAL_TIMESTAMP_HEADER: timestamp, + INTERNAL_SIGNATURE_HEADER: signature, + "X-Trace-Id": trace_id, + }, + timeout=self.request_timeout, + ) + response.raise_for_status() + return response.json() From 35cdde05af3ce3a07a685a5280da93d458508671 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:25:11 +0100 Subject: [PATCH 08/21] feat: enforce KYC on card issuance and fund loading --- card_platform_service/main.py | 138 ++++++++++++++++++++++++---------- 1 file changed, 99 insertions(+), 39 deletions(-) diff --git a/card_platform_service/main.py b/card_platform_service/main.py index e1cb08f..7562eeb 100644 --- a/card_platform_service/main.py +++ b/card_platform_service/main.py @@ -1,62 +1,122 @@ +"""Nexus card platform API with KYC-gated sandbox operations.""" + +from __future__ import annotations + import logging import time +from decimal import Decimal +from typing import Any + +import requests from fastapi import FastAPI, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field + from .clients import CardPlatformLogic -# Structured logging logging.basicConfig( level=logging.INFO, - format='{"timestamp":"%(asctime)s","service":"card-platform","level":"%(levelname)s","message":"%(message)s"}' + format='{"timestamp":"%(asctime)s","service":"card-platform","level":"%(levelname)s","message":"%(message)s"}', ) logger = logging.getLogger(__name__) -app = FastAPI(title="Digital Debit Card Platform API", description="Project 2: Client-facing API for Card/KYC.") +app = FastAPI( + title="Nexus Digital Debit Card Platform API", + description="KYC-gated, sandbox-first card issuance and funding API.", + version="2.0.0", +) card_logic = CardPlatformLogic() +class CardIssuanceRequest(BaseModel): + user_id: str = Field(min_length=1, max_length=128) + first_name: str = Field(min_length=1, max_length=100) + last_name: str = Field(min_length=1, max_length=100) + + +class FundTransferRequest(BaseModel): + user_id: str = Field(min_length=1, max_length=128) + fiat_amount: Decimal = Field(gt=0, max_digits=18, decimal_places=2) + fiat_currency: str = Field(min_length=3, max_length=3) + + +def _approved_kyc_tier(user_id: str) -> int: + try: + kyc_tier = card_logic.get_user_kyc_tier(user_id) + except Exception as exc: + logger.exception("KYC lookup failed") + raise HTTPException(status_code=503, detail="KYC service is unavailable") from exc + + if kyc_tier is None: + raise HTTPException(status_code=404, detail="User was not found") + if not card_logic.is_kyc_tier_approved(kyc_tier): + raise HTTPException( + status_code=403, + detail=f"KYC Tier {card_logic.minimum_kyc_tier} or higher is required", + ) + return kyc_tier + + @app.get("/health") -def health_check(): - """Health check endpoint for Kubernetes probes.""" - return {"status": "healthy", "service": "card-platform-service", "timestamp": time.time()} +def health_check() -> dict[str, Any]: + return { + "status": "healthy", + "service": "card-platform-service", + "card_mode": card_logic.card_mode, + "timestamp": time.time(), + } @app.get("/ready") -def readiness_check(): - """Readiness check - verifies database connectivity.""" - db_ok = card_logic.db_conn is not None - if not db_ok: - raise HTTPException(status_code=503, detail="Database not connected") - return {"status": "ready", "database": "connected"} +def readiness_check() -> dict[str, Any]: + missing: list[str] = [] + if not card_logic.converter_internal_url: + missing.append("CONVERTER_INTERNAL_URL") + if not card_logic.internal_service_secret: + missing.append("INTERNAL_SERVICE_SECRET") + if missing: + raise HTTPException( + status_code=503, + detail={"status": "not_ready", "missing_configuration": missing}, + ) + if not card_logic.check_database(): + raise HTTPException(status_code=503, detail="Database is unavailable") + return {"status": "ready", "card_mode": card_logic.card_mode} -class CardIssuanceRequest(BaseModel): - user_id: str - first_name: str - last_name: str - -class FundTransferRequest(BaseModel): - user_id: str - fiat_amount: float - fiat_currency: str @app.post("/api/v1/cards/issue") -def issue_card_endpoint(request: CardIssuanceRequest): - """Client endpoint to request a new crypto debit card.""" - kyc_status = card_logic.get_user_kyc_status(request.user_id) - - if not card_logic.is_kyc_tier_approved(kyc_status): - raise HTTPException(status_code=403, detail=f"KYC status is {kyc_status}. Requires Tier 3.") +def issue_card_endpoint(request: CardIssuanceRequest) -> dict[str, Any]: + kyc_tier = _approved_kyc_tier(request.user_id) + try: + result = card_logic.issue_new_card(request.user_id, kyc_tier) + return {"status": "success", "data": result} + except PermissionError as exc: + raise HTTPException(status_code=403, detail="Card issuance is not permitted") from exc + except RuntimeError as exc: + logger.error("Card issuance blocked by configuration: %s", exc) + raise HTTPException(status_code=503, detail="Card issuance is not configured") from exc + except Exception as exc: + logger.exception("Card issuance failed") + raise HTTPException(status_code=500, detail="Card issuance failed") from exc - result = card_logic.issue_new_card(request.user_id, kyc_status) - return {"status": "Card Issued", "card_id": result.get("card_id")} @app.post("/api/v1/funds/load") -def load_card_funds(request: FundTransferRequest): - """Client endpoint to load fiat funds, which triggers the conversion via Project 1.""" - - # **DEPENDENCY CALL TO PROJECT 1** - try: - result = card_logic.transfer_fiat_to_crypto(request.dict()) - return {"status": "Conversion Initiated", "details": result} - except Exception as e: - raise HTTPException(status_code=503, detail=f"Service unavailable: {e}") +def load_card_funds(request: FundTransferRequest) -> dict[str, Any]: + _approved_kyc_tier(request.user_id) + try: + result = card_logic.transfer_fiat_to_crypto( + user_id=request.user_id, + fiat_amount=request.fiat_amount, + fiat_currency=request.fiat_currency, + ) + return {"status": "conversion_initiated", "details": result} + except requests.Timeout as exc: + raise HTTPException(status_code=504, detail="Converter service timed out") from exc + except requests.RequestException as exc: + logger.exception("Converter service request failed") + raise HTTPException(status_code=503, detail="Converter service is unavailable") from exc + except RuntimeError as exc: + logger.error("Fund loading blocked by configuration: %s", exc) + raise HTTPException(status_code=503, detail="Fund loading is not configured") from exc + except Exception as exc: + logger.exception("Fund loading failed") + raise HTTPException(status_code=500, detail="Fund loading failed") from exc From 0db908bd9b2f21b11706ef1adf36f907ba20e672 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:25:38 +0100 Subject: [PATCH 09/21] test: add development test dependencies --- requirements-dev.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7af6bb0 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest +httpx From e7d0c2b4a9730f55b68f7f27918e4dc45cf80fc0 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:25:50 +0100 Subject: [PATCH 10/21] test: cover internal request authentication --- tests/test_security.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_security.py diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..9e8765f --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,35 @@ +from common.security import sign_internal_request, verify_internal_request + + +def test_internal_signature_round_trip(): + payload = b'{"amount":"25.00"}' + timestamp, signature = sign_internal_request("test-secret", payload, timestamp=1_000) + + assert verify_internal_request( + "test-secret", + payload, + timestamp, + signature, + now=1_000, + ) + + +def test_internal_signature_rejects_tampering_and_stale_requests(): + payload = b'{"amount":"25.00"}' + timestamp, signature = sign_internal_request("test-secret", payload, timestamp=1_000) + + assert not verify_internal_request( + "test-secret", + b'{"amount":"26.00"}', + timestamp, + signature, + now=1_000, + ) + assert not verify_internal_request( + "test-secret", + payload, + timestamp, + signature, + now=1_301, + tolerance_seconds=300, + ) From 64e6262eb4e5491308e78badd9b915c9ebaf0580 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:25:57 +0100 Subject: [PATCH 11/21] test: verify sandbox BTC conversion safety --- tests/test_conversion_logic.py | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/test_conversion_logic.py diff --git a/tests/test_conversion_logic.py b/tests/test_conversion_logic.py new file mode 100644 index 0000000..118b1b0 --- /dev/null +++ b/tests/test_conversion_logic.py @@ -0,0 +1,39 @@ +from decimal import Decimal + +import pytest + +from converter_service.logic import ConversionLogic + + +def test_sandbox_conversion_is_deterministic_and_non_live(monkeypatch): + monkeypatch.setenv("PAYMENTS_MODE", "sandbox") + monkeypatch.setenv("ALLOW_LIVE_PAYMENTS", "false") + monkeypatch.setenv("AML_SINGLE_TRANSACTION_LIMIT", "10000") + monkeypatch.setenv("SUPPORTED_FIAT_CURRENCIES", "USD") + monkeypatch.setenv("SANDBOX_BTC_RATES_JSON", '{"USD":"50000"}') + + logic = ConversionLogic() + result = logic.execute_conversion_and_payout(Decimal("100.00"), "usd", "user-1") + + assert result["status"] == "simulated" + assert result["payment_mode"] == "sandbox" + assert result["btc_amount_sent"] == "0.00200000" + assert result["satoshis_sent"] == 200000 + + +def test_conversion_rejects_amount_above_aml_limit(monkeypatch): + monkeypatch.setenv("PAYMENTS_MODE", "sandbox") + monkeypatch.setenv("AML_SINGLE_TRANSACTION_LIMIT", "100") + logic = ConversionLogic() + + with pytest.raises(ValueError, match="AML limit"): + logic.execute_conversion_and_payout("100.01", "USD", "user-1") + + +def test_live_mode_fails_closed_without_live_approval(monkeypatch): + monkeypatch.setenv("PAYMENTS_MODE", "live") + monkeypatch.setenv("ALLOW_LIVE_PAYMENTS", "false") + logic = ConversionLogic() + + with pytest.raises(RuntimeError, match="Live payments are disabled"): + logic.execute_conversion_and_payout("10", "USD", "user-1") From 8c0e17f13bb16b018abae3e846d542b23de40845 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:26:06 +0100 Subject: [PATCH 12/21] test: cover persistent idempotency requirements --- tests/test_idempotency.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_idempotency.py diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py new file mode 100644 index 0000000..c7f28ed --- /dev/null +++ b/tests/test_idempotency.py @@ -0,0 +1,28 @@ +from converter_service.idempotency import IdempotencyStore + + +def test_in_memory_idempotency_for_non_production(monkeypatch): + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.setenv("APP_ENV", "test") + store = IdempotencyStore() + + assert store.begin("tx-1") is True + assert store.begin("tx-1") is False + assert store.get_completed_response("tx-1") is None + + response = {"status": "simulated"} + store.complete("tx-1", response) + assert store.get_completed_response("tx-1") == response + + +def test_production_requires_persistent_idempotency(monkeypatch): + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.setenv("APP_ENV", "production") + store = IdempotencyStore() + + try: + store.begin("tx-1") + except RuntimeError as exc: + assert "DATABASE_URL" in str(exc) + else: + raise AssertionError("production idempotency must fail closed without a database") From dbdef67d3f89032e67e72f5b24f50ba3274091c3 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:26:15 +0100 Subject: [PATCH 13/21] test: validate Vercel service routing --- tests/test_vercel_config.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/test_vercel_config.py diff --git a/tests/test_vercel_config.py b/tests/test_vercel_config.py new file mode 100644 index 0000000..4efd898 --- /dev/null +++ b/tests/test_vercel_config.py @@ -0,0 +1,12 @@ +import json +from pathlib import Path + + +def test_vercel_config_is_valid_and_routes_services(): + config = json.loads(Path("vercel.json").read_text(encoding="utf-8")) + + destinations = {route["src"]: route["dest"] for route in config["routes"]} + assert destinations["/api/v1/(.*)"] == "/api/card_service.py" + assert destinations["/webhook/(.*)"] == "/api/converter_service.py" + assert destinations["/internal/(.*)"] == "/api/converter_service.py" + assert destinations["/health"] == "/api/index.py" From 323e20631306185156c68a6e3762fee3dd89120c Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:26:39 +0100 Subject: [PATCH 14/21] ci: add safe validation workflow --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6972f44 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-dev.txt + - name: Validate deployment configuration + run: python -m json.tool vercel.json > /dev/null + - name: Compile Python modules + run: python -m compileall -q api card_platform_service converter_service common + - name: Run tests + run: pytest -q From 2e40ea83eb0255525c00ea7c5a0534d4f24d7926 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:26:46 +0100 Subject: [PATCH 15/21] docs: add safe environment variable template --- .env.example | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0721468 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +APP_ENV=development +PAYMENTS_MODE=sandbox +CARD_ISSUANCE_MODE=sandbox +ALLOW_LIVE_PAYMENTS=false +ALLOW_LIVE_CARD_ISSUANCE=false +INTERNAL_SERVICE_SECRET= +INTERNAL_REQUEST_TIMEOUT_SECONDS=8 +CONVERTER_INTERNAL_URL=http://localhost:8000 +DATABASE_URL= +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=fintech +DB_USER=fintech_user +DB_PASSWORD= +DB_SSLMODE=prefer +KYC_MINIMUM_TIER=3 +AML_SINGLE_TRANSACTION_LIMIT=10000 +SUPPORTED_FIAT_CURRENCIES=USD,EUR,GBP +SANDBOX_BTC_RATES_JSON={"USD":"70000","EUR":"76000","GBP":"82000"} +STRIGA_WEBHOOK_SECRET= +STRIGA_API_BASE_URL= +STRIGA_API_KEY= From 56edac2feac98e0b04fc706a4c9851155eb62a50 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:28:18 +0100 Subject: [PATCH 16/21] db: add core Nexus ledger and transaction schema --- migrations/001_core_schema.sql | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 migrations/001_core_schema.sql diff --git a/migrations/001_core_schema.sql b/migrations/001_core_schema.sql new file mode 100644 index 0000000..676ec48 --- /dev/null +++ b/migrations/001_core_schema.sql @@ -0,0 +1,57 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + kyc_tier SMALLINT NOT NULL DEFAULT 0 CHECK (kyc_tier BETWEEN 0 AND 5), + kyc_status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS nexus_idempotency_keys ( + idempotency_key TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN ('processing', 'completed')), + response_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS nexus_ledger_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + actor_type TEXT NOT NULL, + actor_id TEXT, + subject_id TEXT, + trace_id TEXT, + event_data JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_nexus_ledger_event_type_created + ON nexus_ledger_events (event_type, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_nexus_ledger_subject_created + ON nexus_ledger_events (subject_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS nexus_conversion_records ( + transaction_key TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id), + fiat_amount NUMERIC(18, 2) NOT NULL CHECK (fiat_amount > 0), + fiat_currency CHAR(3) NOT NULL, + btc_amount NUMERIC(24, 8) NOT NULL CHECK (btc_amount >= 0), + satoshis BIGINT NOT NULL CHECK (satoshis >= 0), + payment_mode TEXT NOT NULL, + status TEXT NOT NULL, + provider_reference TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS nexus_card_records ( + card_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id), + card_mode TEXT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMIT; From 0ccf0f9033c4da54b87f6f2c3f03ad5ebadbe6c8 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:28:35 +0100 Subject: [PATCH 17/21] feat: add append-only Nexus audit ledger writer --- common/audit.py | 80 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 common/audit.py diff --git a/common/audit.py b/common/audit.py new file mode 100644 index 0000000..9799520 --- /dev/null +++ b/common/audit.py @@ -0,0 +1,80 @@ +"""Append-only audit event writer for Nexus financial workflows.""" + +from __future__ import annotations + +import json +import logging +import os +import uuid +from typing import Any + +import psycopg2 + +logger = logging.getLogger(__name__) + + +class AuditLogger: + """Write structured events to PostgreSQL, with a development log fallback.""" + + def __init__(self) -> None: + self.database_url = os.getenv("DATABASE_URL", "").strip() + self.app_env = os.getenv("APP_ENV", "development").strip().lower() + + def record( + self, + event_type: str, + *, + actor_type: str, + actor_id: str | None = None, + subject_id: str | None = None, + trace_id: str | None = None, + event_data: dict[str, Any] | None = None, + ) -> str: + event_id = str(uuid.uuid4()) + data = event_data or {} + + if not self.database_url: + if self.app_env == "production": + raise RuntimeError("DATABASE_URL is required for production audit logging") + logger.info( + "AUDIT event_id=%s event_type=%s actor_type=%s subject_id=%s trace_id=%s data=%s", + event_id, + event_type, + actor_type, + subject_id, + trace_id, + json.dumps(data, default=str, sort_keys=True), + ) + return event_id + + connection = psycopg2.connect(self.database_url) + try: + with connection: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO nexus_ledger_events ( + event_id, + event_type, + actor_type, + actor_id, + subject_id, + trace_id, + event_data + ) + VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb) + """, + ( + event_id, + event_type, + actor_type, + actor_id, + subject_id, + trace_id, + json.dumps(data, default=str), + ), + ) + finally: + connection.close() + + return event_id From 8808bad72bba347bbfcde0f58f65d2783abfad2a Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:29:19 +0100 Subject: [PATCH 18/21] docs: define Nexus completion and production gates --- README.md | 101 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 6acf2f1..cef0a71 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,81 @@ -# fintech-microservices-core +# Nexus Fintech Microservices Core -Microservices Core for Alliance Trust Realty's Crypto Debit Card Platform. Features a secure, HMAC-validated Fiat-to-Bitcoin converter and the client-facing card management API. +Secure backend foundation for Alliance Trust Realty's Nexus Bitcoin banking platform. -## Vercel Deployment (API Gateway) +## Current operating state -This repository now includes a root-level `vercel.json` that maps paths to the two FastAPI services: +- Fiat-to-BTC conversion: **sandbox only** +- Card issuance: **sandbox only** +- Live payments: disabled unless explicitly approved and implemented through a verified provider adapter +- Live card issuance: disabled unless explicitly approved and implemented through a verified provider adapter +- KYC: Tier 3 or higher is required for card issuance and fund loading +- Internal service traffic: HMAC-SHA256 authenticated with timestamp replay protection +- Transaction processing: idempotent, with PostgreSQL required in production -- `/api/v1/*` -> `card_platform_service` -- `/webhook/*` and `/internal/*` -> `converter_service` -- `/` and `/health` -> lightweight health app (`api/index.py`) +## Services -If your Vercel deployment previously returned `404: NOT_FOUND`, redeploy from the repository root so Vercel picks up this configuration. +### Card platform -## Nexus Prototype +- `POST /api/v1/cards/issue` +- `POST /api/v1/funds/load` +- `GET /health` +- `GET /ready` -An interactive HTML demo of the Nexus platform dashboard is available at: +### Fiat-to-BTC converter -- `prototype/nexus-demo.html` — Simulates card issuance, fund loading, compliance checks, and displays system status for all agents. +- `POST /internal/transfer_funds` +- `POST /webhook/fiat_received` +- `GET /health` +- `GET /ready` -## Foundry — Agent Definitions & Schemas +### Gateway health -The `foundry/` directory contains the agent specifications, configuration schemas, and workflow definitions for the Nexus platform's AI-powered agent layer. +- `GET /` +- `GET /health` -### Agents (`foundry/agents/`) +## Local setup -| Agent | Description | -|-------|-------------| -| [ComplianceAgent](foundry/agents/ComplianceAgent.md) | Enforces KYC/AML regulatory requirements | -| [SecurityAgent](foundry/agents/SecurityAgent.md) | Validates HMAC signatures, rate limiting, anomaly detection | -| [LedgerAgent](foundry/agents/LedgerAgent.md) | Maintains immutable append-only event ledger | -| [PlatformAgent](foundry/agents/PlatformAgent.md) | Orchestrates workflows, health monitoring, deployments | -| [BuildAgent](foundry/agents/BuildAgent.md) | Manages CI/CD pipeline and locked-build enforcement | +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements-dev.txt +cp .env.example .env +pytest -q +``` -### Schemas (`foundry/schemas/`) +Apply the PostgreSQL schema before production-style testing: -| Schema | Description | -|--------|-------------| -| [compliance.schema.json](foundry/schemas/compliance.schema.json) | KYC tier rules, AML thresholds, reporting config | -| [security.schema.json](foundry/schemas/security.schema.json) | HMAC settings, rate limits, anomaly thresholds | -| [ledger.schema.json](foundry/schemas/ledger.schema.json) | Ledger event entry format and retention policies | -| [platform.schema.json](foundry/schemas/platform.schema.json) | Service registry, workflow settings, retry policies | -| [build.schema.json](foundry/schemas/build.schema.json) | CI pipeline, artifact config, promotion policies | +```bash +psql "$DATABASE_URL" -f migrations/001_core_schema.sql +``` -### Workflows (`foundry/workflows/`) +Never commit real secrets. Store `INTERNAL_SERVICE_SECRET`, database credentials, webhook secrets, and future provider credentials in the deployment platform's encrypted secret store. -| Workflow | Description | -|----------|-------------| -| [nexus-locked-build.yaml](foundry/workflows/nexus-locked-build.yaml) | Gated build-and-deploy pipeline with agent validation gates | +## Vercel deployment -## Scripts +The root `vercel.json` routes: -| File | Description | -|------|-------------| -| [scripts/foundry/RUN_WORKFLOW.md](scripts/foundry/RUN_WORKFLOW.md) | Step-by-step guide to running the locked-build workflow | -| [scripts/foundry/PROMPTS.md](scripts/foundry/PROMPTS.md) | LLM prompt templates for invoking each Foundry agent | +- `/api/v1/*` to the card service +- `/webhook/*` and `/internal/*` to the converter service +- `/` and `/health` to the gateway health app + +Deploy from the repository root so Vercel discovers the Python entrypoints under `api/`. + +## Nexus prototype and Foundry + +- `prototype/nexus-demo.html` provides the existing interactive dashboard demonstration. +- `foundry/agents/` contains Compliance, Security, Ledger, Platform, and Build agent specifications. +- `foundry/schemas/` contains the policy schemas. +- `foundry/workflows/nexus-locked-build.yaml` contains the gated workflow design. + +## Production gate + +A production release is not complete until all of the following are independently verified: + +1. CI passes on the pull request. +2. PostgreSQL migrations are applied and backed up. +3. Deployment secrets are configured outside source control. +4. Webhook signature behavior is confirmed against the chosen provider's official contract. +5. Sandbox card and conversion flows pass end-to-end tests. +6. Security and compliance reviewers approve the release. +7. The owner explicitly approves any future live-provider implementation. From e82e7d520435dbd397c37f2ee7ace6714a8078fa Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:33:34 +0100 Subject: [PATCH 19/21] feat: add Nexus gateway authentication helper --- common/gateway_auth.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 common/gateway_auth.py diff --git a/common/gateway_auth.py b/common/gateway_auth.py new file mode 100644 index 0000000..44518df --- /dev/null +++ b/common/gateway_auth.py @@ -0,0 +1,14 @@ +"""Authentication helper for requests from the Nexus server gateway.""" + +from __future__ import annotations + +import hmac +import os + + +def validate_gateway_key(supplied_key: str | None) -> bool: + """Validate the configured gateway API key using constant-time comparison.""" + expected_key = os.getenv("NEXUS_GATEWAY_API_KEY", "").strip() + if not expected_key or not supplied_key: + return False + return hmac.compare_digest(expected_key, supplied_key.strip()) From e9dbbb72974e5f7ef400778afc9a76c5f046cde6 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:34:11 +0100 Subject: [PATCH 20/21] test: cover Nexus gateway credential validation --- tests/test_gateway_auth.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/test_gateway_auth.py diff --git a/tests/test_gateway_auth.py b/tests/test_gateway_auth.py new file mode 100644 index 0000000..83bbaee --- /dev/null +++ b/tests/test_gateway_auth.py @@ -0,0 +1,12 @@ +from common.gateway_auth import validate_gateway_key + + +def test_gateway_key_requires_configuration(monkeypatch): + monkeypatch.delenv("NEXUS_GATEWAY_API_KEY", raising=False) + assert validate_gateway_key("anything") is False + + +def test_gateway_key_matches_in_constant_time(monkeypatch): + monkeypatch.setenv("NEXUS_GATEWAY_API_KEY", "test-gateway-key") + assert validate_gateway_key("test-gateway-key") is True + assert validate_gateway_key("wrong-key") is False From ab059f5f445ab2ba05f97ee902ab64e39381af71 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Sun, 21 Jun 2026 13:34:41 +0100 Subject: [PATCH 21/21] docs: define safe Nexus gateway integration boundary --- docs/GATEWAY_INTEGRATION.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/GATEWAY_INTEGRATION.md diff --git a/docs/GATEWAY_INTEGRATION.md b/docs/GATEWAY_INTEGRATION.md new file mode 100644 index 0000000..f163862 --- /dev/null +++ b/docs/GATEWAY_INTEGRATION.md @@ -0,0 +1,18 @@ +# Nexus Gateway Integration + +The product-facing Nexus API must act as a backend-for-frontend. Browser code must never receive provider credentials, database credentials, webhook secrets, internal service secrets, or future card/payment provider keys. + +## Current approved integration scope + +The Nexus application may read the core gateway and service health endpoints to display system status. + +Financial write routes are not approved for exposure through the Nexus API until the Python card service enforces `NEXUS_GATEWAY_API_KEY` (or an equivalent authenticated user/session design) on every card and funding endpoint. + +## Safety rules + +- Keep `PAYMENTS_MODE=sandbox`. +- Keep `CARD_ISSUANCE_MODE=sandbox`. +- Keep both live-approval flags disabled. +- Do not proxy `/internal/*` or `/webhook/*` through the browser-facing application. +- Do not place any core secret in `app/config.js`, HTML, or a `NEXT_PUBLIC_*` variable. +- Treat health responses as untrusted upstream data and apply timeouts.