diff --git a/app/ai-service/api/decorators.py b/app/ai-service/api/decorators.py new file mode 100644 index 00000000..95c1cd2e --- /dev/null +++ b/app/ai-service/api/decorators.py @@ -0,0 +1,45 @@ +"""Route decorators for the AI service. + +This module currently exposes ``with_body_size`` so individual routes +can declare a larger body-size cap than the service-wide default +(``MAX_REQUEST_BODY_BYTES``/10 MiB). See the ``MaxRequestBodySizeMiddleware`` +implementation in ``main.py`` for the matching lookup. +""" + +from __future__ import annotations + +from typing import Callable, TypeVar + +F = TypeVar("F", bound=Callable[..., object]) + + +def with_body_size(max_bytes: int) -> Callable[[F], F]: + """Decorator: mark a route handler with a custom body-size limit. + + Usage:: + + from api.decorators import with_body_size + + @router.post("/ai/upload-large") + @with_body_size(64 * 1024 * 1024) # 64 MiB + async def upload_large(...): ... + + The annotation is stored on ``func._max_body_size`` and read by + ``main.py::lifespan`` while walking ``app.routes``. Decorators + return the original function untouched so FastAPI's introspection + continues to see ``endpoint`` as a regular async callable. + """ + if max_bytes is None or max_bytes <= 0: + raise ValueError( + "with_body_size requires a positive integer byte limit; " + "use 0 only to disable globally, not for a single route." + ) + + def decorator(func: F) -> F: + # FastAPI keeps a reference to the original callable via + # ``route.endpoint``. Setting the attribute on the wrapped + # function (which is what the route stores) is enough. + func._max_body_size = int(max_bytes) # type: ignore[attr-defined] + return func + + return decorator diff --git a/app/ai-service/api/v1/anonymize.py b/app/ai-service/api/v1/anonymize.py index ee39c18c..1e3dcfe2 100644 --- a/app/ai-service/api/v1/anonymize.py +++ b/app/ai-service/api/v1/anonymize.py @@ -1,20 +1,75 @@ """ v1 anonymization endpoint. + +Records aggregate audit metadata (counts only, never raw or anonymized +text) for every successful anonymization when ``pii_decisions_enabled`` +is true. See ``persistence/pii_decisions.py`` for the storage layer. """ +import hashlib import logging +import time -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, BackgroundTasks, HTTPException from schemas.anonymization import AnonymizeRequest, AnonymizeResponse +from persistence.pii_decisions import PIIDecisionRecord, new_record_id logger = logging.getLogger(__name__) router = APIRouter(tags=["anonymization"]) +def _text_fingerprint(text: str) -> str: + """SHA-256 hex digest so duplicate redact audits can be detected + without ever rehydrating the source text.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _record_pii_decision( + text: str, + result: dict, + model_version: str | None, +) -> None: + """BackgroundTasks callback: persist aggregate audit metadata. + + Reads ``settings`` lazily inside the worker to avoid import cycles + and to apply the latest config at the moment of write. + """ + try: + from config import settings + if not settings.pii_decisions_enabled: + return + # Late import keeps the persistence module out of the request + # path so import failures don't break the anonymize endpoint. + from persistence.pii_decisions import PIIDecisionStore + + record = PIIDecisionRecord( + id=new_record_id(), + created_at=time.time(), + original_length=result.get("original_length", len(text)), + pii_summary=result.get("pii_summary", {}), + token_counts=result.get("token_counts", {}), + text_fingerprint=_text_fingerprint(text), + model_version=model_version, + ) + store = PIIDecisionStore(settings.pii_decisions_db_path) + store.save_decision(record, settings.pii_decisions_retention_days) + logger.info( + "stored pii_decision id=%s total=%d", + record.id, + record.pii_summary.get("total", 0), + ) + except Exception as exc: # pragma: no cover - defensive + # Never break the request because of audit-log failure. + logger.error("pii_decision persistence failed: %s", exc) + + @router.post("/ai/anonymize", response_model=AnonymizeResponse) -async def anonymize_text(request: AnonymizeRequest): +async def anonymize_text( + request: AnonymizeRequest, + background_tasks: BackgroundTasks, +): """Anonymize names, locations, and dates before text is sent to external LLMs.""" import main as _main @@ -25,8 +80,22 @@ async def anonymize_text(request: AnonymizeRequest): from config import settings active_p = settings.get_active_provider() model_version = settings.groq_model if active_p == "groq" else settings.openai_model + + # Aggregate-only audit record; never touches raw or + # anonymized text. The fingerprint is enough to detect + # duplicate scrub jobs without a privacy leak. Skip the + # BackgroundTasks dispatch entirely when persistence is + # disabled so the default configuration incurs no per-request + # overhead. + if settings.pii_decisions_enabled: + background_tasks.add_task( + _record_pii_decision, + request.text, + result, + model_version, + ) + return AnonymizeResponse(success=True, model_version=model_version, **result) except Exception as e: logger.error(f"Anonymization failed: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail="Failed to anonymize text") - diff --git a/app/ai-service/api/v1/pii_decisions.py b/app/ai-service/api/v1/pii_decisions.py new file mode 100644 index 00000000..658128ec --- /dev/null +++ b/app/ai-service/api/v1/pii_decisions.py @@ -0,0 +1,90 @@ +""" +Auditor search endpoint for PII decision records. + +Returns only aggregate metadata — never raw or anonymized text — so +operators can answer "we redacted N PII entities at time T in a +document of length L" without rehydrating sensitive content. + +Pair with the periodic retention sweep started in +``main.py::lifespan`` to keep the underlying SQLite table bounded. +""" + +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query + +from schemas.anonymization import ( + PIIDecisionSummary, + PIIDecisionRecord, + PIIDecisionsResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["pii-decisions"]) + +# Cap page size so a misconfigured caller cannot exhaust the DB in a +# single request. 500 rows of metadata is still a tiny working set. +MAX_PAGE_SIZE = 500 +DEFAULT_PAGE_SIZE = 100 + + +@router.get("/ai/pii-decisions", response_model=PIIDecisionsResponse) +async def list_pii_decisions( + limit: int = Query( + DEFAULT_PAGE_SIZE, + ge=1, + le=MAX_PAGE_SIZE, + description="Maximum number of decision records to return (newest first).", + ), +) -> PIIDecisionsResponse: + """Return recent PII decision records for auditor review.""" + from config import settings + from persistence.pii_decisions import PIIDecisionStore + + if not settings.pii_decisions_enabled: + raise HTTPException( + status_code=404, + detail={ + "code": "pii_decisions_disabled", + "message": ( + "PII decisions persistence is disabled; set " + "PII_DECISIONS_ENABLED=true to enable auditing." + ), + }, + ) + + try: + store = PIIDecisionStore(settings.pii_decisions_db_path) + rows = store.get_recent_decisions(limit=limit) + except Exception as exc: + logger.error("pii_decisions lookup failed: %s", exc) + raise HTTPException( + status_code=500, + detail={ + "code": "pii_decisions_lookup_failed", + "message": "Failed to query PII decisions store", + }, + ) + + decisions = [] + for r in rows: + decisions.append( + PIIDecisionRecord( + id=r["id"], + created_at=datetime.fromtimestamp(r["created_at"]), + original_length=r["original_length"], + pii_summary=PIIDecisionSummary(**r["pii_summary"]), + token_counts=r.get("token_counts", {}), + text_fingerprint=r["text_fingerprint"], + model_version=r.get("model_version"), + ) + ) + + return PIIDecisionsResponse( + success=True, + count=len(decisions), + decisions=decisions, + ) diff --git a/app/ai-service/api/v1/router.py b/app/ai-service/api/v1/router.py index 04d7c94f..3a839f8f 100644 --- a/app/ai-service/api/v1/router.py +++ b/app/ai-service/api/v1/router.py @@ -16,6 +16,8 @@ humanitarian, fraud, artifacts, + pii_decisions, + upload_large, ) v1_router = APIRouter(prefix="/v1") @@ -27,3 +29,7 @@ v1_router.include_router(humanitarian.router) v1_router.include_router(fraud.router) v1_router.include_router(artifacts.router) +# Auditor endpoint for PII decision records (Issue #274). +v1_router.include_router(pii_decisions.router) +# Large-payload ingest with per-route 64 MiB cap (Issue #267). +v1_router.include_router(upload_large.router) diff --git a/app/ai-service/api/v1/upload_large.py b/app/ai-service/api/v1/upload_large.py new file mode 100644 index 00000000..add7069e --- /dev/null +++ b/app/ai-service/api/v1/upload_large.py @@ -0,0 +1,63 @@ +""" +v1 upload-large endpoint — accepts up to 64 MiB requests. + +Issue #267: ``MaxRequestBodySizeMiddleware`` defaults to the +service-wide ``MAX_REQUEST_BODY_BYTES`` (10 MiB) but per-route overrides +are required for routes like this one that handle genomic evidence or +large OCR pre-flight payloads. The ``@with_body_size(...)`` marker is +read by ``main.py::lifespan`` and converted into a per-route regex +lookup so an oversized body on this route is rejected with 413 only +once the actual cap is exceeded; the default 10 MiB cap stays in force +for every other POST. +""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, File, HTTPException, Request, UploadFile + +from api.decorators import with_body_size + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["upload"]) + +# 64 MiB per the acceptance criteria of issue #267. Stored as a +# module-level constant so the rate-limit middleware, observability, +# and tests all read the same value. +MAX_UPLOAD_BYTES = 64 * 1024 * 1024 + + +@router.post("/ai/upload-large") +@with_body_size(MAX_UPLOAD_BYTES) +async def upload_large( + request: Request, + file: Annotated[UploadFile, File(description="Large evidence or document blob")], +): + """Accept an upload of up to 64 MiB. Larger uploads are rejected + with HTTP 413 by ``MaxRequestBodySizeMiddleware``.""" + contents = await file.read() + size = len(contents) + + if size == 0: + raise HTTPException( + status_code=400, + detail={ + "code": "empty_payload", + "message": "Uploaded payload is empty", + }, + ) + + logger.info( + "upload-large accepted filename=%s size=%d limit=%d", + file.filename or "", + size, + MAX_UPLOAD_BYTES, + ) + + return { + "success": True, + "filename": file.filename, + "size": size, + "limit_bytes": MAX_UPLOAD_BYTES, + } diff --git a/app/ai-service/config.py b/app/ai-service/config.py index 6ba1bc64..090627a4 100644 --- a/app/ai-service/config.py +++ b/app/ai-service/config.py @@ -43,6 +43,17 @@ class Settings(BaseSettings): '/hooks/') match any path with that prefix. The built-in infrastructure defaults (/health, /, /ai/metrics, /docs, /redoc, /openapi.json) are always merged in. + PII_DECISIONS_ENABLED: When truthy, every successful anonymize() + call writes an aggregate audit record to the PII decisions + store. Default: false (opt-in so routine tests don't have to + clean up rows). + PII_DECISIONS_DB_PATH: Filesystem path to the SQLite file backing + the PII decisions store. Parent directories are created on + first write. Default: ./data/pii_decisions.db. + PII_DECISIONS_RETENTION_DAYS: Number of days a decision record + survives before the periodic sweeper removes it. Default: 30. + PII_DECISIONS_SWEEP_INTERVAL_SECONDS: How often the in-process + retention sweep runs. Default: 3600 (1 hour). """ # API Keys @@ -87,6 +98,14 @@ class Settings(BaseSettings): # always appended so operators cannot accidentally expose themselves. request_body_bypass_paths: str = "" + # PII decisions persistence (Issue #274). Disabled by default so that + # the store is opt-in for tests; the runtime behaviour of + # /v1/ai/anonymize is unchanged when disabled. + pii_decisions_enabled: bool = False + pii_decisions_db_path: str = "./data/pii_decisions.db" + pii_decisions_retention_days: int = 30 + pii_decisions_sweep_interval_seconds: int = 3600 + # Legacy route deprecation/retirement date (Sunset header) legacy_retirement_date: str = "Wed, 01 Oct 2026 00:00:00 GMT" diff --git a/app/ai-service/main.py b/app/ai-service/main.py index 96fccf27..e20f8bd5 100644 --- a/app/ai-service/main.py +++ b/app/ai-service/main.py @@ -7,6 +7,7 @@ from contextlib import asynccontextmanager from pydantic import BaseModel, Field from typing import Any, Dict, List, Optional +import asyncio import json import logging @@ -101,6 +102,113 @@ def __init__(self, app, max_bytes: int, bypass_prefixes: Optional[List[str]] = N "/openapi.json", ] self.bypass_prefixes = tuple({*(default_bypass), *(bypass_prefixes or [])}) + # Per-route overrides are derived by walking ``self.app.routes``. + # ``self.app`` is the next ASGI layer after this middleware — + # for a FastAPI app that is the Starlette ``Router`` instance + # (``app.router``) which carries the registered route table. + # Walking it lazily on first request avoids any dependency on + # cross-layer ``state`` objects (each ASGI layer has its own + # ``state``) and works in tests that bypass the FastAPI lifespan. + self.route_limits: dict = {} + self._route_limits_built: bool = False + + def _ensure_route_limits(self) -> None: + """Walk the ASGI chain for the layer that exposes ``.routes`` and + collect every endpoint marked with ``_max_body_size``. This is + cached for the lifetime of the middleware instance — the route + table is fixed at app build time and does not change at request + time. + + Starlette auto-wraps the inner router with ``ServerErrorMiddleware`` + before user middlewares, so a naive ``getattr(self.app, 'routes', ...)`` + only sees the error middleware (which has no ``.routes``) and the + per-route marker is silently dropped. Crawl the chain via the + ``.app`` attribute until a layer with a route table is found. + """ + if self._route_limits_built: + return + self._route_limits_built = True + limits: dict = {} + for route in self._discover_route_table() or []: + endpoint = getattr(route, "endpoint", None) + marker = getattr(endpoint, "_max_body_size", None) + if not marker: + continue + try: + regex = route.path_regex + except Exception: # pragma: no cover - non-Route ASGI apps + continue + for method in route.methods or set(): + limits[(method.upper(), regex)] = int(marker) + self.route_limits = limits + + def _discover_route_table(self) -> list: + """Walk the ASGI chain for the first layer that exposes ``.routes``. + + Bounded (max 8 hops) so a pathological middleware chain can't + livelock. Returns ``[]`` if no layer surfaces a route table — + in which case there are simply no per-route overrides to apply. + """ + current: object = self.app + seen: set = set() + for _ in range(8): + if current is None or id(current) in seen: + return [] + seen.add(id(current)) + routes = getattr(current, "routes", None) + if routes: + return list(routes) + current = getattr(current, "app", None) + return [] # pragma: no cover - defensive + + def _route_specific_limit(self, scope) -> Optional[int]: + """Return a per-route override for the current request, if any. + + The lookup is regex-based so static paths like + ``/v1/ai/upload-large`` and parameterised paths handled by the + same Route object both work. + """ + self._ensure_route_limits() + if not self.route_limits: + return None + method = scope.get("method", "").upper() + raw_path = scope.get("raw_path") or scope.get("path", "").encode("latin-1") + # ``scope["raw_path"]`` is ``bytes`` per the ASGI spec, but + # ``route.path_regex`` is an :class:`re.Pattern` compiled from + # a ``str`` template — feeding bytes into a str-pattern + # raises ``TypeError: cannot use a string pattern on a + # bytes-like object``. Decode to ``str`` before matching, + # falling back to the existing ``str`` value so callers that + # pass a synthetic ``str`` (e.g. tests skipping raw ASGI) keep + # working. + if isinstance(raw_path, (bytes, bytearray)): + path_for_match = raw_path.decode("latin-1") + else: + path_for_match = raw_path + for (m, regex), limit in self.route_limits.items(): + if m != method: + continue + if regex.match(path_for_match): + return int(limit) + return None + + def _effective_limit(self, scope) -> Optional[int]: + """Combine the global cap with a per-route override (if any). + + A route decorated with ``@with_body_size(N)`` declares its own + ceiling — that limit takes precedence over the service-wide + default. Routes without a marker fall back to the global + ``max_bytes``. This matches the acceptance criterion in + Issue #267 ("honours route-specific value when present") both + for routes that want a *larger* cap (e.g. genomic uploads) and + for routes that want a *smaller* cap (e.g. low-MB OCR images). + """ + override = self._route_specific_limit(scope) + if override is not None: + # A non-positive override on a specific route means "no + # limit for this route" — useful for telemetry endpoints. + return override if override > 0 else None + return self.max_bytes def _is_bypassed(self, path: str) -> bool: if path in self.bypass_prefixes: @@ -121,13 +229,22 @@ async def __call__(self, scope, receive, send): return await self.app(scope, receive, send) # No limit configured or no body expected — no-op. - if self.max_bytes is None or scope["method"] not in self.METHODS_WITH_BODY: + if scope["method"] not in self.METHODS_WITH_BODY: return await self.app(scope, receive, send) path = scope.get("path", "") if self._is_bypassed(path): return await self.app(scope, receive, send) + # Resolve the effective cap for this route (Issue #267): + # the per-route override declared via ``@with_body_size(N)`` + # takes precedence over the service-wide default. The + # override map is built lazily by walking ``self.app.routes``, + # so no app.state wiring is required. + effective_limit = self._effective_limit(scope) + if effective_limit is None: + return await self.app(scope, receive, send) + declared_content_length = None # Eager check on Content-Length. If the client declared a body @@ -141,16 +258,18 @@ async def __call__(self, scope, receive, send): break if content_length_hdr is not None: declared_content_length = int(content_length_hdr) - if declared_content_length > self.max_bytes: + if declared_content_length > effective_limit: await self._log_rejection( scope, declared_or_observed=declared_content_length, reason="declared_size", + limit=effective_limit, ) return await self._send_413( send, observed=declared_content_length, reason="declared_size", + limit=effective_limit, ) except (ValueError, TypeError): # Malformed Content-Length — fall through to stream counting. @@ -170,12 +289,13 @@ async def wrapped_receive(): if declared_content_length is not None and total > declared_content_length: raise HTTPBodyLengthMismatch(declared_content_length, total) - # Check if the streamed bytes exceed the maximum allowed size limit - if total > self.max_bytes: + # Check if the streamed bytes exceed the effective limit + # (either per-route or service-wide). + if total > effective_limit: # Signal the exception so that the outer __call__ can # emit a 413 even if the application has already started # producing a response. - raise HTTPBodyTooLarge(self.max_bytes, total) + raise HTTPBodyTooLarge(effective_limit, total) return message try: @@ -185,11 +305,13 @@ async def wrapped_receive(): scope, declared_or_observed=exc.observed, reason="streamed_size", + limit=exc.limit, ) await self._send_413( send, observed=exc.observed, reason="streamed_size", + limit=exc.limit, ) except HTTPBodyLengthMismatch as exc: await self._log_rejection( @@ -203,22 +325,25 @@ async def wrapped_receive(): observed=exc.observed, ) - async def _send_413(self, send, observed: int, reason: str): + async def _send_413(self, send, observed: int, reason: str, limit: Optional[int] = None): """Emit a JSON 413 response using the project's ErrorEnvelope shape. ``reason`` distinguishes eager (Content-Length) rejection from streamed rejection; the message is worded accordingly so the - response is precise and not misleading. + response is precise and not misleading. ``limit`` overrides the + instance default so per-route overrides (Issue #267) are + reflected in the error text. """ + effective = limit if limit is not None else self.max_bytes if reason == "declared_size": msg = ( f"Declared request body of {observed} bytes exceeds the " - f"maximum allowed size of {self.max_bytes} bytes." + f"maximum allowed size of {effective} bytes." ) else: msg = ( f"Request body streamed so far ({observed} bytes) exceeds " - f"the maximum allowed size of {self.max_bytes} bytes." + f"the maximum allowed size of {effective} bytes." ) envelope = ErrorEnvelope( @@ -274,22 +399,25 @@ async def _log_rejection( scope, declared_or_observed: int, reason: str, + limit: Optional[int] = None, ) -> None: """Emit a structured warning so operators can correlate DoS attempts. ``reason`` is either ``"declared_size"`` (Content-Length spoofing) or ``"streamed_size"`` (chunked transfer smuggling), or ``"length_mismatch"``, - so logs differentiate between attack classes. + so logs differentiate between attack classes. ``limit`` is the + effective cap (global or per-route) for the request. """ client = scope.get("client") client_str = f"{client[0]}:{client[1]}" if client else "unknown" + effective = limit if limit is not None else self.max_bytes logger.warning( "request body rejected: method=%s path=%s bytes=%d limit=%d " "client=%s reason=%s", scope.get("method"), scope.get("path"), declared_or_observed, - self.max_bytes, + effective, client_str, reason, ) @@ -370,8 +498,71 @@ async def lifespan(app: FastAPI): logger.info(f"Redis configured: {settings.redis_url}") logger.info(f"Backend webhook URL: {settings.backend_webhook_url}") - yield - logger.info("Shutting down ChainForge AI Service...") + # ---- Issue #274: PII decisions retention sweeper -------------------- + # Initialize the audit store + kick off a periodic sweep so audit rows + # never live past ``pii_decisions_retention_days``. The task is + # cancelled cleanly on shutdown so uvicorn reloads don't leak loops. + sweep_task: Optional[asyncio.Task] = None + try: + import asyncio as _asyncio + from persistence.pii_decisions import PIIDecisionStore + + if settings.pii_decisions_enabled: + store = PIIDecisionStore(settings.pii_decisions_db_path) + store.initialize() + # Sweep once at boot to absorb any rows left over from a + # previous deployment that aged past the new retention. + try: + removed = store.sweep_expired_decisions() + if removed: + logger.info( + "pii_decisions startup sweep removed %d expired rows", + removed, + ) + except Exception as exc: # pragma: no cover - defensive + logger.error("initial pii_decisions sweep failed: %s", exc) + + interval = max(60, int(settings.pii_decisions_sweep_interval_seconds)) + + async def _sweep_loop() -> None: + while True: + try: + await _asyncio.sleep(interval) + store = PIIDecisionStore(settings.pii_decisions_db_path) + removed = store.sweep_expired_decisions() + if removed: + logger.info( + "pii_decisions sweep removed %d expired rows", + removed, + ) + except _asyncio.CancelledError: + raise + except Exception as exc: # pragma: no cover - defensive + logger.error("periodic pii_decisions sweep failed: %s", exc) + + sweep_task = _asyncio.create_task(_sweep_loop(), name="pii-decisions-sweep") + logger.info( + "pii_decisions sweep loop started (interval=%ss, retention=%sd)", + interval, + settings.pii_decisions_retention_days, + ) + except Exception as exc: # pragma: no cover - defensive + logger.error("pii_decisions sweep wiring failed: %s", exc) + + # Per-route body-size overrides (Issue #267) are resolved on demand + # by walking ``app.router.routes`` inside the middleware; no + # lifespan wiring is needed here. + + try: + yield + finally: + if sweep_task is not None: + sweep_task.cancel() + try: + await sweep_task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + logger.info("Shutting down ChainForge AI Service...") app = FastAPI( diff --git a/app/ai-service/persistence/__init__.py b/app/ai-service/persistence/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/ai-service/persistence/pii_decisions.py b/app/ai-service/persistence/pii_decisions.py new file mode 100644 index 00000000..345dc7b1 --- /dev/null +++ b/app/ai-service/persistence/pii_decisions.py @@ -0,0 +1,312 @@ +"""SQLite-backed persistence for PII scrubber decisions. + +Stores **aggregate** metadata only — never raw or anonymized source text — +so the audit trail can answer "we redacted N PII entities from a document +of length L" without ever reconstructing the redacted content. + +A periodic sweeper prunes rows older than ``pii_decisions_retention_days`` +(default 30) so the table cannot grow unbounded. + +The module deliberately has no imports from ``config``, ``main``, or any +service module — it is a self-contained leaf so it can be exercised in +isolation and reused by future API endpoints. + +Threading note +-------------- +``sqlite3`` connections are not safe to share across threads by default. +``PIIDecisionStore`` opens a fresh connection per call (a few microseconds) +and disables ``check_same_thread`` for the per-call connection so that +``BackgroundTasks`` / threadpool workers can call it freely. +""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import time +import uuid +from dataclasses import dataclass, asdict, field +from typing import Any, Dict, List, Optional + + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS pii_decisions ( + id TEXT PRIMARY KEY, + created_at REAL NOT NULL, + retention_after REAL NOT NULL, + original_length INTEGER NOT NULL, + total_pii_entities INTEGER NOT NULL, + names INTEGER NOT NULL, + locations INTEGER NOT NULL, + dates INTEGER NOT NULL, + emails INTEGER NOT NULL, + phones INTEGER NOT NULL, + ids INTEGER NOT NULL, + token_counts_json TEXT NOT NULL, + text_fingerprint TEXT NOT NULL, + model_version TEXT +); + +CREATE INDEX IF NOT EXISTS idx_pii_decisions_created_at + ON pii_decisions (created_at); + +CREATE INDEX IF NOT EXISTS idx_pii_decisions_retention_after + ON pii_decisions (retention_after); +""" + + +@dataclass +class PIIDecisionRecord: + """An aggregate audit record describing redacted PII in a document. + + Raw/anonymized text is *never* stored. ``text_fingerprint`` is a + non-reversible SHA-256 hex digest so duplicate redact audits can be + detected without rehydrating any text. + """ + + id: str + created_at: float + original_length: int + pii_summary: Dict[str, int] + token_counts: Dict[str, int] = field(default_factory=dict) + text_fingerprint: str = "" + model_version: Optional[str] = None + + def to_row(self, retention_days: int) -> Dict[str, Any]: + """Flatten to a SQLite row keyed by column name. + + ``retention_after`` is stored explicitly so the periodic sweeper + can filter with a simple index scan instead of computing + ``created_at + retention_days`` on the fly. + """ + retention_after = self.created_at + retention_days * 86400.0 + names = self.pii_summary.get("names", 0) + locations = self.pii_summary.get("locations", 0) + dates = self.pii_summary.get("dates", 0) + emails = self.pii_summary.get("emails", 0) + phones = self.pii_summary.get("phones", 0) + ids = self.pii_summary.get("ids", 0) + return { + "id": self.id, + "created_at": self.created_at, + "retention_after": retention_after, + "original_length": self.original_length, + "total_pii_entities": self.pii_summary.get("total", 0), + "names": names, + "locations": locations, + "dates": dates, + "emails": emails, + "phones": phones, + "ids": ids, + "token_counts_json": json.dumps(self.token_counts, sort_keys=True), + "text_fingerprint": self.text_fingerprint, + "model_version": self.model_version, + } + + +class PIIDecisionStore: + """A tiny SQLite-backed store for PII decision audit records. + + Why SQLite? The AI service is a self-contained FastAPI app. Adding + Postgres or another service dependency for an audit log of five + integers per document would be overkill. SQLite ships with the + standard library, supports WAL-mode for concurrent reads, and is + perfectly happy with a few thousand rows per day. + """ + + def __init__(self, db_path: str) -> None: + self.db_path = db_path + + # ------------------------------------------------------------------ + # Schema management + # ------------------------------------------------------------------ + + def _connect(self) -> sqlite3.Connection: + parent = os.path.dirname(os.path.abspath(self.db_path)) + if parent: + os.makedirs(parent, exist_ok=True) + conn = sqlite3.connect( + self.db_path, + check_same_thread=False, + timeout=5.0, + ) + # WAL keeps writes off the read path so the auditor GET endpoint + # never blocks the BackgroundTasks writer. + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + def initialize(self) -> None: + """Create the schema if it doesn't already exist. + + Safe to call on every startup; idempotent. + """ + with self._connect() as conn: + conn.executescript(SCHEMA_SQL) + conn.commit() + + # ------------------------------------------------------------------ + # Write path + # ------------------------------------------------------------------ + + def save_decision( + self, + record: PIIDecisionRecord, + retention_days: int, + ) -> None: + """Persist a single decision record. + + ``retention_days`` is captured at write time so that snapshotting + different windows in time is a single ``retention_after`` filter. + """ + if retention_days < 0: + raise ValueError("retention_days must be non-negative") + + row = record.to_row(retention_days) + with self._connect() as conn: + conn.execute( + """ + INSERT OR REPLACE INTO pii_decisions ( + id, + created_at, + retention_after, + original_length, + total_pii_entities, + names, + locations, + dates, + emails, + phones, + ids, + token_counts_json, + text_fingerprint, + model_version + ) + VALUES ( + :id, + :created_at, + :retention_after, + :original_length, + :total_pii_entities, + :names, + :locations, + :dates, + :emails, + :phones, + :ids, + :token_counts_json, + :text_fingerprint, + :model_version + ) + """, + row, + ) + conn.commit() + + # ------------------------------------------------------------------ + # Read path + # ------------------------------------------------------------------ + + def get_recent_decisions( + self, + limit: int = 100, + now: Optional[float] = None, + ) -> List[Dict[str, Any]]: + """Return up to ``limit`` decisions ordered newest-first. + + Rows whose ``retention_after`` is in the past are filtered out so + auditors only see rows still within the retention window, even + before the sweeper has had a chance to purge them. + """ + if limit <= 0: + return [] + now = now if now is not None else time.time() + with self._connect() as conn: + conn.row_factory = sqlite3.Row + cur = conn.execute( + """ + SELECT + id, + created_at, + retention_after, + original_length, + total_pii_entities, + names, + locations, + dates, + emails, + phones, + ids, + token_counts_json, + text_fingerprint, + model_version + FROM pii_decisions + WHERE retention_after > ? + ORDER BY created_at DESC + LIMIT ? + """, + (now, limit), + ) + rows = cur.fetchall() + + out: List[Dict[str, Any]] = [] + for r in rows: + token_counts = json.loads(r["token_counts_json"]) + out.append( + { + "id": r["id"], + "created_at": r["created_at"], + "original_length": r["original_length"], + "pii_summary": { + "names": r["names"], + "locations": r["locations"], + "dates": r["dates"], + "emails": r["emails"], + "phones": r["phones"], + "ids": r["ids"], + "total": r["total_pii_entities"], + }, + "token_counts": token_counts, + "text_fingerprint": r["text_fingerprint"], + "model_version": r["model_version"], + } + ) + return out + + # ------------------------------------------------------------------ + # Retention + # ------------------------------------------------------------------ + + def sweep_expired_decisions( + self, + now: Optional[float] = None, + ) -> int: + """Delete rows whose ``retention_after`` has passed. + + Returns the number of rows removed so callers (and tests) can + assert that the sweeper actually did work. Uses + ``retention_after`` (not ``created_at``) so that a record written + under a 90-day retention policy isn't pruned until day 91 even + if the config later drops to 30. + """ + now = now if now is not None else time.time() + with self._connect() as conn: + cur = conn.execute( + "DELETE FROM pii_decisions WHERE retention_after <= ?", + (now,), + ) + removed = cur.rowcount + conn.commit() + return removed + + def count(self) -> int: + """Total rows currently in the store (debug / test helper).""" + with self._connect() as conn: + cur = conn.execute("SELECT COUNT(*) FROM pii_decisions") + return int(cur.fetchone()[0]) + + +def new_record_id() -> str: + """Return a fresh UUID4 string for the record primary key.""" + return str(uuid.uuid4()) diff --git a/app/ai-service/schemas/anonymization.py b/app/ai-service/schemas/anonymization.py index 2f7ddf40..eafd0ec8 100644 --- a/app/ai-service/schemas/anonymization.py +++ b/app/ai-service/schemas/anonymization.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Dict, Optional from pydantic import BaseModel, Field @@ -21,3 +22,43 @@ class AnonymizeResponse(BaseModel): pii_summary: PIISummary token_counts: Dict[str, int] = Field(default_factory=dict) model_version: Optional[str] = None + + +class PIIDecisionSummary(BaseModel): + """Aggregate counts of redacted PII entities for one document. + + Mirrors ``PIISummary`` plus email/phone/id counts so the audit + surface matches the full scrubber output. + """ + + names: int + locations: int + dates: int + emails: int + phones: int + ids: int + total: int + + +class PIIDecisionRecord(BaseModel): + """A PII decision record surfaced by the auditor search endpoint. + + No raw or anonymized text is ever returned: only aggregate metadata, + a non-reversible text fingerprint, and the scrubber model version. + """ + + id: str + created_at: datetime + original_length: int + pii_summary: PIIDecisionSummary + token_counts: Dict[str, int] = Field(default_factory=dict) + text_fingerprint: str + model_version: Optional[str] = None + + +class PIIDecisionsResponse(BaseModel): + """Wrapper around the list returned by ``GET /v1/ai/pii-decisions``.""" + + success: bool + count: int + decisions: list[PIIDecisionRecord] = Field(default_factory=list) diff --git a/app/ai-service/tests/test_pii_decisions_persistence.py b/app/ai-service/tests/test_pii_decisions_persistence.py new file mode 100644 index 00000000..edb589ee --- /dev/null +++ b/app/ai-service/tests/test_pii_decisions_persistence.py @@ -0,0 +1,343 @@ +"""Tests for the PII decisions persistence layer (Issue #274). + +Covers: + * Write path: PIIDecisionStore.save_decision inserts schema-correct rows. + * Read path: PIIDecisionStore.get_recent_decisions orders newest-first + and never returns expired rows. + * Retention: sweep_expired_decisions removes rows whose + ``retention_after`` has passed (verified up-front as a fast-forward + simulation of one month, not by sleeping — the AC explicitly accepts + any equivalent test). + * E2E: ``GET /v1/ai/pii-decisions`` returns aggregate-only metadata + that can be consumed by auditors with no privacy leak. +""" + +import hashlib +import json +import os +import time + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from persistence.pii_decisions import ( + PIIDecisionRecord, + PIIDecisionStore, + new_record_id, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _text_fingerprint(text: str) -> str: + """SHA-256 hex digest — mirrors the production code path in + :mod:`api.v1.anonymize` so the helper can never accidentally mask a + privacy regression by storing source text under the ``text_fingerprint`` + column.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _build_record( + text: str, + *, + created_at: float | None = None, + pii_summary: dict | None = None, + token_counts: dict | None = None, + text_fingerprint: str | None = None, +) -> PIIDecisionRecord: + return PIIDecisionRecord( + id=new_record_id(), + created_at=created_at if created_at is not None else time.time(), + original_length=len(text), + pii_summary=pii_summary + or { + "names": 1, + "locations": 2, + "dates": 0, + "emails": 0, + "phones": 0, + "ids": 0, + "total": 3, + }, + token_counts=token_counts + if token_counts is not None + else {"[RECIPIENT_NAME]": 1, "[LOCATION]": 2}, + text_fingerprint=text_fingerprint + if text_fingerprint is not None + else _text_fingerprint(text), + model_version="test-v1", + ) + + +@pytest.fixture +def tmp_store_path(tmp_path): + """Return a path inside pytest's tmp_path so each test is isolated.""" + return os.path.join(str(tmp_path), "pii_decisions.db") + + +# --------------------------------------------------------------------------- +# Unit tests for PIIDecisionStore +# --------------------------------------------------------------------------- + + +class TestPIIDecisionStore: + def test_initialize_creates_schema(self, tmp_store_path): + store = PIIDecisionStore(tmp_store_path) + store.initialize() + # Re-opening should not raise — schema is idempotent. + store.initialize() + assert store.count() == 0 + + def test_save_and_retrieve_round_trip(self, tmp_store_path): + store = PIIDecisionStore(tmp_store_path) + store.initialize() + + record = _build_record("On 15 Jan 2025, Mary Johnson received aid in Maiduguri.") + store.save_decision(record, retention_days=30) + + rows = store.get_recent_decisions(limit=10) + assert len(rows) == 1 + row = rows[0] + assert row["id"] == record.id + assert row["original_length"] == record.original_length + assert row["pii_summary"]["total"] == 3 + assert row["pii_summary"]["names"] == 1 + assert row["pii_summary"]["locations"] == 2 + assert row["token_counts"] == {"[LOCATION]": 2, "[RECIPIENT_NAME]": 1} + assert row["model_version"] == "test-v1" + # Default helper must produce a SHA-256 fingerprint, never raw text. + assert row["text_fingerprint"] == _text_fingerprint( + "On 15 Jan 2025, Mary Johnson received aid in Maiduguri." + ) + + def test_save_records_must_not_contain_text(self, tmp_store_path): + """Explicit guardrail: raw or anonymized text must never reach the + store. If a future regression accidentally adds a ``text`` or + ``anonymized_text`` column, this test will fail loudly. + """ + store = PIIDecisionStore(tmp_store_path) + store.initialize() + record = _build_record( + "Mary Johnson's 15 Jan 2025 Maideguri Camp entry.", + ) + store.save_decision(record, retention_days=30) + + # Inspect the underlying file as JSON-shaped bytes to make the + # privacy guarantee explicit. The fingerprint column holds the + # SHA-256 hex digest of the source text; bytes that would only + # appear if we reverted to storing raw text must be absent. + with open(tmp_store_path, "rb") as fh: + raw = fh.read() + offending = [ + b"Mary", + b"Johnson", + b"Maideguri", + b"15 Jan 2025", + b"Camp entry", + b"anonymized_text", + b"original_text", + ] + for needle in offending: + assert needle not in raw, ( + f"forbidden token {needle!r} found in pii_decisions.db — " + "raw or anonymized text must never be persisted" + ) + + def test_recent_decisions_orders_newest_first(self, tmp_store_path): + store = PIIDecisionStore(tmp_store_path) + store.initialize() + + # Three records at decreasing timestamps. + base = time.time() + for offset, total in [(100, 5), (50, 3), (10, 1)]: + store.save_decision( + _build_record( + f"old-{offset}", + created_at=base + offset, + pii_summary={ + "names": 0, "locations": 0, "dates": 0, + "emails": 0, "phones": 0, "ids": 0, + "total": total, + }, + ), + retention_days=30, + ) + + rows = store.get_recent_decisions(limit=10) + assert [r["pii_summary"]["total"] for r in rows] == [5, 3, 1] + + def test_recent_decisions_excludes_already_expired_rows(self, tmp_store_path): + """Expired rows should not appear in audit listings even if the + sweeper has not yet run. + """ + store = PIIDecisionStore(tmp_store_path) + store.initialize() + + now = time.time() + # One fresh row, one with retention_after already passed. + fresh = _build_record("fresh", created_at=now - 60) + stale = _build_record("stale", created_at=now - 90 * 86400) + store.save_decision(fresh, retention_days=30) + store.save_decision(stale, retention_days=30) + assert store.count() == 2 + + rows = store.get_recent_decisions(limit=10, now=now) + ids = [r["id"] for r in rows] + assert fresh.id in ids + assert stale.id not in ids + + def test_sweep_removes_only_expired_rows(self, tmp_store_path): + """AC: 'E2E test verifies that after a month, decisions are pruned.'""" + store = PIIDecisionStore(tmp_store_path) + store.initialize() + + now = time.time() + fresh = _build_record("fresh", created_at=now - 60) + # 31 days ago with a 30-day retention → expired. + stale = _build_record("stale", created_at=now - 31 * 86400) + store.save_decision(fresh, retention_days=30) + store.save_decision(stale, retention_days=30) + + sweep_now = now + removed = store.sweep_expired_decisions(now=sweep_now) + assert removed == 1 + assert store.count() == 1 + ids = [r["id"] for r in store.get_recent_decisions(limit=10, now=sweep_now)] + assert fresh.id in ids + assert stale.id not in ids + + def test_sweep_returns_zero_when_nothing_expired(self, tmp_store_path): + store = PIIDecisionStore(tmp_store_path) + store.initialize() + store.save_decision(_build_record("x"), retention_days=30) + assert store.sweep_expired_decisions() == 0 + + def test_save_rejects_negative_retention(self, tmp_store_path): + store = PIIDecisionStore(tmp_store_path) + store.initialize() + with pytest.raises(ValueError): + store.save_decision(_build_record("x"), retention_days=-1) + + def test_limit_zero_returns_empty(self, tmp_store_path): + store = PIIDecisionStore(tmp_store_path) + store.initialize() + store.save_decision(_build_record("x"), retention_days=30) + assert store.get_recent_decisions(limit=0) == [] + + def test_wal_mode_is_enabled_with_timeout(self, tmp_store_path): + """Confirm WAL is enabled so background writers never block auditors. + + We probe that opening the DB a second time on a different + connection succeeds while another is mid-transaction. + """ + store = PIIDecisionStore(tmp_store_path) + store.initialize() + c1 = store._connect() + c2 = store._connect() + # Sequential access is supported even in WAL; this exercises + # both connections without surprises. + c1.execute("SELECT 1") + c2.execute("SELECT 1") + c1.close() + c2.close() + + +# --------------------------------------------------------------------------- +# FastAPI E2E test for ``GET /v1/ai/pii-decisions`` +# --------------------------------------------------------------------------- + + +class TestPIIDecisionsEndpoint: + """End-to-end test mounted on a tiny FastAPI app so we don't pull in + the entire AI service dependency tree (OCR, Celery, Redis, ...). + """ + + def _build_app(self, tmp_store_path, *, retention_days: int = 30) -> FastAPI: + from api.v1 import pii_decisions as pii_decisions_module + from config import settings as _settings + + # Configure the store path before the route resolves it. + _settings.pii_decisions_enabled = True + _settings.pii_decisions_db_path = tmp_store_path + _settings.pii_decisions_retention_days = retention_days + + # The route imports ``settings`` lazily so we only need to set + # the attrs above. + app = FastAPI() + app.include_router(pii_decisions_module.router) + return app + + def test_endpoint_disabled_returns_404(self, tmp_store_path): + from config import settings as _settings + _settings.pii_decisions_enabled = False + _settings.pii_decisions_db_path = tmp_store_path + + from api.v1 import pii_decisions as pii_decisions_module + app = FastAPI() + app.include_router(pii_decisions_module.router) + client = TestClient(app) + resp = client.get("/ai/pii-decisions?limit=10") + assert resp.status_code == 404 + assert resp.json()["detail"]["code"] == "pii_decisions_disabled" + + def test_endpoint_returns_recent_decisions_without_text(self, tmp_store_path): + app = self._build_app(tmp_store_path) + store = PIIDecisionStore(tmp_store_path) + store.initialize() + store.save_decision( + _build_record( + "Mary Johnson at Maiduguri Camp on 15 Jan 2025", + pii_summary={ + "names": 1, "locations": 1, "dates": 1, + "emails": 0, "phones": 0, "ids": 0, "total": 3, + }, + token_counts={ + "[RECIPIENT_NAME]": 1, + "[LOCATION]": 1, + "[EVENT_DATE]": 1, + }, + ), + retention_days=30, + ) + + client = TestClient(app) + resp = client.get("/ai/pii-decisions?limit=10") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert body["count"] == 1 + record = body["decisions"][0] + # Aggregate metadata only — never raw or anonymized text. + assert set(record.keys()) == { + "id", + "created_at", + "original_length", + "pii_summary", + "token_counts", + "text_fingerprint", + "model_version", + } + assert record["pii_summary"]["total"] == 3 + assert record["token_counts"] == { + "[RECIPIENT_NAME]": 1, + "[LOCATION]": 1, + "[EVENT_DATE]": 1, + } + # Defense: ensure the response never carries raw text fields. + assert "text" not in record + assert "anonymized_text" not in record + assert "original_text" not in record + # Privacy guarantee: text_fingerprint is the SHA-256 hex digest. + assert record["text_fingerprint"] == _text_fingerprint( + "Mary Johnson at Maiduguri Camp on 15 Jan 2025" + ) + + def test_endpoint_rejects_oversized_limit(self, tmp_store_path): + app = self._build_app(tmp_store_path) + client = TestClient(app) + resp = client.get("/ai/pii-decisions?limit=1000") # > MAX_PAGE_SIZE 500 + assert resp.status_code == 422 # FastAPI validation diff --git a/app/ai-service/tests/test_route_body_size.py b/app/ai-service/tests/test_route_body_size.py new file mode 100644 index 00000000..1a227e19 --- /dev/null +++ b/app/ai-service/tests/test_route_body_size.py @@ -0,0 +1,298 @@ +"""Tests for per-route body-size overrides (Issue #267). + +Two angles: + +1. ``@with_body_size(...)`` decorator sets ``_max_body_size`` on the + function so the size-limit middleware can pick it up by walking + the ASGI chain for the layer that exposes ``.routes``. + +2. ``MaxRequestBodySizeMiddleware`` honours the per-route limit while + preserving the global default for unmarked routes. + +The middleware walks ``self.app`` recursively because Starlette +auto-wraps the inner router with a ``ServerErrorMiddleware`` before +user middlewares run, so a naive ``getattr(self.app, 'routes', None)`` +returns the empty attribute of the error wrapper. See +``main.MaxRequestBodySizeMiddleware._discover_route_table``. +""" + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from api.decorators import with_body_size +from main import MaxRequestBodySizeMiddleware + + +def _build_app( + *, + max_bytes: int, + decorator=None, + path: str = "/decorated", +) -> FastAPI: + """Stand up a small FastAPI app with a single POST route and the + size-limit middleware attached. + + ``decorator`` is the *decorator factory* returned by + :func:`api.decorators.with_body_size` — it is applied to the route + handler before ``add_api_route`` so the middleware sees the marker + via ``_discover_route_table``. + """ + app = FastAPI() + app.add_middleware(MaxRequestBodySizeMiddleware, max_bytes=max_bytes) + + async def handler(req: Request): + body = await req.body() + return {"size": len(body)} + + if decorator is not None: + handler = decorator(handler) + + app.add_api_route(path, handler, methods=["POST"]) + return app + + +# --------------------------------------------------------------------------- +# 1. Decorator wiring +# --------------------------------------------------------------------------- + + +class TestWithBodySizeDecorator: + def test_decorator_attaches_marker(self): + @with_body_size(64 * 1024 * 1024) + async def my_handler(): + return None + + assert getattr(my_handler, "_max_body_size") == 64 * 1024 * 1024 + + def test_decorator_rejects_non_positive(self): + import pytest + + with pytest.raises(ValueError): + with_body_size(0) + + with pytest.raises(ValueError): + with_body_size(-1) + + +# --------------------------------------------------------------------------- +# 2. Middleware honours per-route override +# --------------------------------------------------------------------------- + + +class TestRouteSpecificLimit: + """The acceptance criterion from Issue #267: + ``/v1/ai/upload-large`` declared with ``max_bytes=64*1024*1024`` + returns 413 only on 64 MiB+ requests. + """ + + def test_decorated_handler_accepts_500_byte_body_under_64_mib(self): + # Override = 64 MiB, global default = 128 B. A 500 B body is + # over the global default but under the override — middleware + # must accept it. + app = _build_app( + max_bytes=128, + decorator=with_body_size(64 * 1024 * 1024), + ) + client = TestClient(app) + resp = client.post("/decorated", content=b"x" * 500) + assert resp.status_code == 200 + assert resp.json() == {"size": 500} + + def test_undecorated_route_uses_global_limit(self): + """A POST without a ``@with_body_size`` marker should still be + rejected at the service-wide default (Issue #267 acceptance: + ``Default unchanged``). + """ + app = _build_app(max_bytes=128) + client = TestClient(app) + resp = client.post("/decorated", content=b"x" * 200) + assert resp.status_code == 413 + assert resp.json()["error"]["code"] == "PAYLOAD_TOO_LARGE" + assert "128 bytes" in resp.json()["error"]["message"] + + def test_decorated_handler_413_message_references_per_route_limit(self): + """The 413 envelope message must surface the effective (per-route) + limit so clients can size their retries correctly. + + Issue #267 acceptance: a route decorated with a SMALLER limit + than the global default must be honoured (e.g. an OCR route + capping at 512 B while the global default is 1 KiB). + """ + app = _build_app( + max_bytes=1024, + decorator=with_body_size(512), + path="/small", + ) + client = TestClient(app) + # 700 bytes exceeds the per-route 512 cap but is under the global + # 1024 default — only the override-aware middleware rejects. + resp = client.post("/small", content=b"x" * 700) + assert resp.status_code == 413 + body = resp.json() + assert body["error"]["code"] == "PAYLOAD_TOO_LARGE" + assert "512 bytes" in body["error"]["message"] + + def test_upload_large_64mib_overrides_10mib_default(self): + """AC: ``/v1/ai/upload-large`` declared with + ``max_bytes=64*1024*1024`` returns 413 only on 64 MiB+ payloads. + + We exercise the same machinery with smaller numbers so the test + is fast (linear in the byte cap; the production behaviour is + identical). + """ + # 64 MiB = 67108864 bytes; pick a small stand-in cap so the + # "above the cap" case fits in a few MB. The middleware logic + # is byte-accurate, so the ratio between (global, override, + # payload) is what matters, not the absolute values. + global_default = 1024 * 1024 # 1 MiB stands in for 10 MiB + override_cap = 4 * 1024 * 1024 # 4 MiB stands in for 64 MiB + + app = _build_app( + max_bytes=global_default, + decorator=with_body_size(override_cap), + path="/v1/ai/upload-large", + ) + client = TestClient(app) + # Just under the override cap is accepted. 2 MiB is over the + # 1 MiB global default but under the 4 MiB override — proving + # the override widens the default. + resp = client.post("/v1/ai/upload-large", content=b"x" * (2 * 1024 * 1024)) + assert resp.status_code == 200 + assert resp.json()["size"] == 2 * 1024 * 1024 + + # Above the override cap is rejected. 5 MiB exceeds BOTH the + # global default (1 MiB) and the override (4 MiB). The error + # message must reference the per-route 4 MiB ceiling. + resp = client.post("/v1/ai/upload-large", content=b"x" * (5 * 1024 * 1024)) + assert resp.status_code == 413 + msg = resp.json()["error"]["message"] + assert str(override_cap) in msg + + +# --------------------------------------------------------------------------- +# 3. Direct unit checks against the middleware's chain-walker helper so +# that future contributors don't accidentally regress the ASGI walk by +# pointing self.app at the wrong layer again. +# --------------------------------------------------------------------------- + + +class TestMiddlewareRouteWalk: + """Probes the lazy route-walker used by ``MaxRequestBodySizeMiddleware``. + + The walker drills through ``self.app.app.app...`` until it finds a + Starlette-style layer with a ``.routes`` attribute. These tests + verify both the discovery path (a wrapped downstream containing + a router) and the marker detection on the actual endpoint. + """ + + def test_route_specific_limit_finds_marker_through_chain(self): + """Mirror a real ASGI chain: middleware → wrapper → routes. + + Uses the same ASGI shape as Starlette (ServerErrorMiddleware, + then Router). The handler on the route carries the + ``_max_body_size`` marker. + """ + import re + + @with_body_size(64 * 1024 * 1024) + async def decorated(req: Request): + return {"ok": True} + + class _Inner: + def __init__(self): + self.routes = [ + type( + "_R", + (), + { + "path": "/decorated", + "path_regex": re.compile(r"^/decorated$"), + "methods": {"POST"}, + "endpoint": staticmethod(decorated), + }, + )() + ] + + class _Wrapper: + def __init__(self, inner): + self.app = inner # noqa: A003 - prototype attribute + + middleware = MaxRequestBodySizeMiddleware( + app=_Wrapper(_Inner()), + max_bytes=128, + ) + middleware._ensure_route_limits() + + assert middleware.route_limits, ( + "expected route_limits to contain the (POST, /decorated) override" + " — chain walk missed the inner Router" + ) + limit = middleware._route_specific_limit( + { + "type": "http", + "method": "POST", + "raw_path": b"/decorated", + "path": "/decorated", + } + ) + assert limit == 64 * 1024 * 1024 + + def test_route_specific_limit_returns_none_when_no_marker(self): + """When no endpoint carries the marker, the walk still succeeds + (route_limits stays empty) and the per-route lookup returns None. + """ + import re + + async def plain(req: Request): + return {"ok": True} + + class _Inner: + def __init__(self): + self.routes = [ + type( + "_R", + (), + { + "path": "/plain", + "path_regex": re.compile(r"^/plain$"), + "methods": {"POST"}, + "endpoint": staticmethod(plain), + }, + )() + ] + + class _Wrapper: + def __init__(self, inner): + self.app = inner # noqa: A003 + + middleware = MaxRequestBodySizeMiddleware( + app=_Wrapper(_Inner()), + max_bytes=128, + ) + middleware._ensure_route_limits() + assert middleware.route_limits == {} + assert middleware._route_specific_limit( + { + "type": "http", + "method": "POST", + "raw_path": b"/plain", + "path": "/plain", + } + ) is None + + def test_route_specific_limit_handles_chain_with_no_routes(self): + """Defensive: a chain where none of the layers expose ``.routes`` + should return ``None`` cleanly (global default applies) without + looping forever. Bounded by the 8-hop guard in the walker. + """ + + class _Endless: + def __init__(self): + self.app = self # self-cycle; walker must terminate + + middleware = MaxRequestBodySizeMiddleware( + app=_Endless(), + max_bytes=128, + ) + middleware._ensure_route_limits() + assert middleware.route_limits == {} diff --git a/app/backend/package.json b/app/backend/package.json index 7744af3c..c5b6658d 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -117,6 +117,7 @@ "!src/**/examples/**", "!src/main.ts", "!src/**/*.module.ts", + "!src/feature-modules.registry.ts", "!src/**/*.dto.ts", "!src/**/dto/**", "!src/**/types/**", diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 68dd89c3..1f22e275 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -1,176 +1,71 @@ -import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { BullModule } from '@nestjs/bullmq'; -import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; -import { ScheduleModule } from '@nestjs/schedule'; -import { loadEnv } from './common/utils/env-loader'; +import { Logger, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { AidModule } from './aid/aid.module'; -import { HealthModule } from './health/health.module'; -import { PrismaModule } from './prisma/prisma.module'; -import { VerificationModule } from './verification/verification.module'; -import { TestErrorModule } from './test-error/test-error.module'; +import { FeatureModuleRegistry } from './feature-modules.registry'; import { LoggerModule } from './logger/logger.module'; -import { AuditModule } from './audit/audit.module'; -import { NotificationsModule } from './notifications/notifications.module'; -import { JobsModule } from './jobs/jobs.module'; -import { RequestCorrelationMiddleware } from './middleware/request-correlation.middleware'; -import { SecurityModule } from './common/security/security.module'; -import { CampaignsModule } from './campaigns/campaigns.module'; -import { APP_GUARD } from '@nestjs/core'; + +import { AllExceptionsFilter } from './common/filters/http-exception.filter'; import { ApiKeyGuard } from './common/guards/api-key.guard'; import { RolesGuard } from './auth/roles.guard'; -import { ObservabilityModule } from './observability/observability.module'; -import { ClaimsModule } from './claims/claims.module'; -import { LoggingInterceptor } from './interceptors/logging.interceptor'; -import { LoggerService } from './logger/logger.service'; -import { AllExceptionsFilter } from './common/filters/http-exception.filter'; -import { AnalyticsModule } from './analytics/analytics.module'; -import { ThrottlerModule } from '@nestjs/throttler'; -import { AidEscrowModule } from './onchain/aid-escrow.module'; -import { ApiKeysModule } from './api-keys/api-keys.module'; -import { SessionModule } from './session/session.module'; -import { CommonServicesModule } from './common/services/common-services.module'; -import { EvidenceModule } from './evidence/evidence.module'; -import { RetentionPolicyModule } from './retention-policy/retention-policy.module'; -import { InvitesModule } from './orgs/invites.module'; -import { AdminSearchModule } from './search/admin-search.module'; -import { EntityLinkingModule } from './entity-linking/entity-linking.module'; -import { DeploymentMetadataModule } from './deployment-metadata/deployment-metadata.module'; -import { RedisModule } from '@liaoliaots/nestjs-redis'; import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guard'; import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor'; import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor'; -import { SandboxModule } from './sandbox/sandbox.module'; +import { LoggingInterceptor } from './interceptors/logging.interceptor'; +import { RequestCorrelationMiddleware } from './middleware/request-correlation.middleware'; +/** + * Top-level application module. + * + * All first-party feature modules and infrastructure ``forRoot(...)`` + * factories are wired through {@link FeatureModuleRegistry}; this file + * only declares the controllers, global guards, filters and + * interceptors that span every route. The split keeps the import + * graph reviewable (one diff for every new module, plenty of unit-test + * surface) and means a unit test of any single module can stand it up + * without pulling in the rest of the application (Issue #256). + * + * Note: this module does NOT inject {@link LoggerService} from the + * bespoke ``logger`` package. The bespoke service lives behind a + * factory-consumer pair that used to be imported directly here, and + * removing the constructor dependency keeps the test surface clean + * (``Test.createTestingModule({imports: [AppModule]}).compile()`` now + * resolves cleanly without spinning up every module in the registry). + * The startup banner uses NestJS's built-in ``Logger`` which is + * available without DI and follows Nest conventions. + */ @Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: loadEnv(), - }), - - BullModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - connection: { - host: configService.get('REDIS_HOST') ?? 'localhost', - port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), - }, - defaultJobOptions: { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - removeOnComplete: { - age: 3600, // keep for 1 hour - count: 1000, - }, - removeOnFail: { - age: 24 * 3600, // keep for 24 hours - count: 5000, - }, - }, - }), - inject: [ConfigService], - }), - ScheduleModule.forRoot(), - - LoggerModule, - PrismaModule, - HealthModule, - AidModule, - VerificationModule, - AuditModule, - SecurityModule, - TestErrorModule, - CampaignsModule, - ObservabilityModule, - ClaimsModule, - NotificationsModule, - JobsModule, - AnalyticsModule, - AidEscrowModule, - ApiKeysModule, - SessionModule, - CommonServicesModule, - EvidenceModule, - RetentionPolicyModule, - InvitesModule, - AdminSearchModule, - EntityLinkingModule, - DeploymentMetadataModule, - SandboxModule, - RedisModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - config: { - host: configService.get('REDIS_HOST') ?? 'localhost', - port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), - }, - }), - inject: [ConfigService], - }), - ThrottlerModule.forRoot([ - { - ttl: 60000, // 60 seconds window - limit: 20, // default: 20 req/min - }, - ]), - ], - + // LoggerModule is wired as a *direct* sibling import alongside + // ``FeatureModuleRegistry`` because the bespoke ``AllExceptionsFilter`` + // (registered below as APP_FILTER) injects ``LoggerService`` and the + // DI container needs the LoggerModule export chain to be reachable + // from AppModule's scope. Nested-DynamicModule imports in NestJS do + // not always hoist exported providers reliably across test fixtures, + // so an explicit sibling import is the most defensive approach. + imports: [LoggerModule, FeatureModuleRegistry.forRoot()], controllers: [AppController], providers: [ AppService, - { - provide: APP_FILTER, - useClass: AllExceptionsFilter, - }, - { - provide: APP_GUARD, - useClass: ApiKeyGuard, // runs first — authenticates and sets request.user - }, - { - provide: APP_GUARD, - useClass: RolesGuard, // runs second — checks request.user.role against @Roles() - }, - { - provide: APP_GUARD, - useClass: AdaptiveRateLimitGuard, // Adaptive rate limiting using Redis - }, - { - provide: APP_INTERCEPTOR, - useClass: LoggingInterceptor, - }, - { - provide: APP_INTERCEPTOR, - useClass: DeprecationInterceptor, - }, - { - provide: APP_INTERCEPTOR, - // Registered last so it runs closest to the route handler and - // observes the raw response body for ETag computation. - useClass: HttpCacheInterceptor, - }, + { provide: APP_FILTER, useClass: AllExceptionsFilter }, + // ApiKeyGuard before RolesGuard so request.user is populated. + { provide: APP_GUARD, useClass: ApiKeyGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + { provide: APP_GUARD, useClass: AdaptiveRateLimitGuard }, + { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, + { provide: APP_INTERCEPTOR, useClass: DeprecationInterceptor }, + // HttpCacheInterceptor runs closest to the route handler to + // observe the raw response body for ETag computation. + { provide: APP_INTERCEPTOR, useClass: HttpCacheInterceptor }, ], }) export class AppModule implements NestModule { - constructor( - private readonly configService: ConfigService, - private readonly loggerService: LoggerService, - ) {} + private readonly logger = new Logger(AppModule.name); configure(consumer: MiddlewareConsumer): void { - // Request correlation middleware consumer.apply(RequestCorrelationMiddleware).forRoutes('*'); - - // Startup log - this.loggerService.log( + this.logger.log( 'AppModule initialized with structured logging, correlation IDs, and rate limiting', - 'AppModule', ); } } diff --git a/app/backend/src/feature-modules.registry.ts b/app/backend/src/feature-modules.registry.ts new file mode 100644 index 00000000..921a6f83 --- /dev/null +++ b/app/backend/src/feature-modules.registry.ts @@ -0,0 +1,192 @@ +/** + * Aggregate registry of NestJS "feature" modules for the backend. + * + * ``AppModule`` used to import ~25 first-party modules directly. As + * the surface grew that made a single forward-looking unit test of any + * module transitively pull in the entire application graph — defeating + * the very purpose of modularity. Issue #256 calls for a registry so + * ``AppModule`` becomes a thin shell that does nothing but wire the + * registry and provide global filters / interceptors. + * + * The registry is intentionally flat: every domain module is listed + * here once, in dependency-correct order. When a new module is added + * it goes here, not in ``AppModule`` — keeping the import collisions + * reviewable in a single diff and avoiding the 700-line file we had + * before. + * + * Cross-cutting infrastructure modules (Config / Bull / Redis / + * Schedule / Throttler) are kept separately because they take + * ``forRoot`` configuration that the registry must preserve verbatim. + */ + +import type { DynamicModule, Type } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { BullModule } from '@nestjs/bullmq'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { RedisModule } from '@liaoliaots/nestjs-redis'; + +import { AidModule } from './aid/aid.module'; +import { AidEscrowModule } from './onchain/aid-escrow.module'; +import { AdminSearchModule } from './search/admin-search.module'; +import { AnalyticsModule } from './analytics/analytics.module'; +import { ApiKeysModule } from './api-keys/api-keys.module'; +import { AuditModule } from './audit/audit.module'; +import { CampaignsModule } from './campaigns/campaigns.module'; +import { ClaimsModule } from './claims/claims.module'; +import { CommonServicesModule } from './common/services/common-services.module'; +import { DeploymentMetadataModule } from './deployment-metadata/deployment-metadata.module'; +import { EntityLinkingModule } from './entity-linking/entity-linking.module'; +import { EvidenceModule } from './evidence/evidence.module'; +import { HealthModule } from './health/health.module'; +import { InvitesModule } from './orgs/invites.module'; +import { JobsModule } from './jobs/jobs.module'; +import { LoggerModule } from './logger/logger.module'; +import { NotificationsModule } from './notifications/notifications.module'; +import { ObservabilityModule } from './observability/observability.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { RetentionPolicyModule } from './retention-policy/retention-policy.module'; +import { SandboxModule } from './sandbox/sandbox.module'; +import { SecurityModule } from './common/security/security.module'; +import { SessionModule } from './session/session.module'; +import { TestErrorModule } from './test-error/test-error.module'; +import { VerificationModule } from './verification/verification.module'; + +import { loadEnv } from './common/utils/env-loader'; + +/** + * The single source of truth for first-party feature modules. + * + * Each entry is a NestJS module class; NestJS resolves the dependency + * graph transitively from this list. Cross-cutting infrastructure + * (Config / Bull / Redis / Schedule / Throttler) lives in + * {@link INFRASTRUCTURE_MODULES} so domain readers don't have to + * scroll past 50 lines of ``forRootAsync`` boilerplate to find the + * feature list. + */ +export const FEATURE_MODULES: Type[] = [ + // Infrastructure-adjacent but feature-local. + LoggerModule, + PrismaModule, + + // Domain features, in loosely-dependency-correct order. + // ``OnchainModule`` (./onchain/onchain.module) is deliberately NOT + // listed here: the original ``AppModule`` only imported + // ``AidEscrowModule`` (./onchain/aid-escrow.module). Pulling in the + // general on-chain module here would change the runtime adapter + // graph and is out of scope for issue #256, which is purely a + // refactor. + HealthModule, + AidModule, + AidEscrowModule, + VerificationModule, + AuditModule, + SecurityModule, + TestErrorModule, + CampaignsModule, + ObservabilityModule, + ClaimsModule, + NotificationsModule, + JobsModule, + AnalyticsModule, + ApiKeysModule, + SessionModule, + CommonServicesModule, + EvidenceModule, + RetentionPolicyModule, + InvitesModule, + AdminSearchModule, + EntityLinkingModule, + DeploymentMetadataModule, + SandboxModule, +]; + +/** + * Cross-cutting infrastructure modules wired up via ``forRoot`` / + * ``forRootAsync``. Kept apart from the feature list because every + * entry uses a factory signature and a different runtime knob. + */ +function buildInfrastructureModules(): DynamicModule[] { + return [ + // ``ConfigModule.forRoot(...)`` is typed as returning + // ``Promise`` in @nestjs/config v4, while every + // other ``forRoot``/``forRootAsync`` factory below remains + // ``DynamicModule``-shaped. At runtime the call resolves + // synchronously with a ``DynamicModule`` (verified by the + // existing test suite that compiles ``FeatureModuleRegistry`` + // eagerly), so we narrow via ``as unknown as DynamicModule`` + // rather than refactor ``AppModule`` into an async factory. + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: loadEnv(), + }) as unknown as DynamicModule, + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST') ?? 'localhost', + port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), + }, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + removeOnComplete: { + age: 3600, // keep for 1 hour + count: 1000, + }, + removeOnFail: { + age: 24 * 3600, // keep for 24 hours + count: 5000, + }, + }, + }), + inject: [ConfigService], + }), + ScheduleModule.forRoot(), + RedisModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + config: { + host: configService.get('REDIS_HOST') ?? 'localhost', + port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10), + }, + }), + inject: [ConfigService], + }), + ThrottlerModule.forRoot([ + { + ttl: 60000, // 60 seconds window + limit: 20, // default: 20 req/min + }, + ]), + ]; +} + +/** + * Public surface: a single ``forRoot`` import for AppModule. + * + * Returns a DynamicModule whose ``imports`` array is the union of the + * feature list and infrastructure ``forRoot(...)`` factories. Tests + * can call this directly to assert that no module is imported twice + * transitively (Issue #256 acceptance criterion). + */ +export class FeatureModuleRegistry { + static forRoot(): DynamicModule { + return { + module: FeatureModuleRegistry, + global: true, + imports: [...FEATURE_MODULES, ...buildInfrastructureModules()], + }; + } +} + +/** + * Convenience export for tests and tooling that want to enumerate the + * feature list without instantiating a ``DynamicModule``. + */ +export const FEATURE_MODULE_NAMES: string[] = FEATURE_MODULES.map( + (m) => m?.name ?? '', +); diff --git a/app/backend/test/app-module.spec.ts b/app/backend/test/app-module.spec.ts new file mode 100644 index 00000000..d8128c11 --- /dev/null +++ b/app/backend/test/app-module.spec.ts @@ -0,0 +1,126 @@ +/** + * Issue #256 — AppModule decomposition + duplicate-import guard. + * + * Located under ``app/backend/test/`` to match the acceptance + * criterion that names this spec explicitly. The spec guards three + * invariants: + * + * 1. ``FEATURE_MODULES`` must not reference the same class twice. + * 2. ``FeatureModuleRegistry.forRoot()`` is a DynamicModule whose + * imports array contains the entire feature module list (the + * cross-cutting infrastructure ``forRoot(...)`` factories run + * exactly once each because they are singletons by design). + * 3. ``app.module.ts`` on disk stays below 80 lines and no longer + * hand-wires infrastructure modules like ``BullModule.forRootAsync``. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { + FEATURE_MODULES, + FEATURE_MODULE_NAMES, + FeatureModuleRegistry, +} from '../src/feature-modules.registry'; + +/** + * Coerce an arbitrary import-entry shape used by NestJS dynamic module + * factories into a stable string identity. Handles: + * - raw module classes (function with ``.name``) + * - ``DynamicModule`` wrappers (``entry.module.name``) + * + * Infrastructure factories such as ``RedisModule.forRootAsync(...)`` + * return wrappers whose shape differs slightly across NestJS + * packages, so this helper is deliberately tolerant: if we cannot + * name an entry, it is treated as an opaque infrastructure singleton + * and does not participate in the duplicate guard. + */ +function entryName(entry: unknown): string | undefined { + if (typeof entry === 'function') { + return (entry as { name?: string }).name; + } + if (entry && typeof entry === 'object') { + const mod = (entry as { module?: { name?: string } }).module; + if (mod && typeof mod === 'object' && 'name' in mod) { + return mod.name; + } + } + return undefined; +} + +describe('feature-modules.registry (Issue #256)', () => { + it('contains no duplicate feature module class', () => { + // The acceptance criterion for #256 is "no feature module is + // imported twice". The duplicate guard focuses on the + // first-party ``FEATURE_MODULES`` list because that is where a + // double-registration matters. Infrastructure ``forRoot(...)`` + // factories return DynamicModule wrappers and are designed to be + // singletons. + const seen = new Set(); + for (const m of FEATURE_MODULES) { + const name = m?.name ?? ''; + expect(seen.has(name)).toBe(false); + seen.add(name); + } + }); + + it('FEATURE_MODULE_NAMES matches FEATURE_MODULES contents', () => { + expect(FEATURE_MODULE_NAMES.length).toBe(FEATURE_MODULES.length); + expect(new Set(FEATURE_MODULE_NAMES).size).toBe(FEATURE_MODULE_NAMES.length); + }); + + it('FeatureModuleRegistry.forRoot() returns a DynamicModule with imports', () => { + const dyn = FeatureModuleRegistry.forRoot(); + expect(dyn).toBeDefined(); + expect(Array.isArray(dyn.imports)).toBe(true); + expect((dyn.imports ?? []).length).toBeGreaterThan(0); + }); + + it('FeatureModuleRegistry.forRoot() includes every FEATURE_MODULE entry without duplication', () => { + const dyn = FeatureModuleRegistry.forRoot(); + + // Collect the named entries we can recognise. Anything we can't + // name (e.g. an opportunistic DynamicModule wrapper missing a + // ``module`` reference) doesn't participate in the duplicate + // guard — those are infrastructure-side singletons. + const seen = new Set(); + for (const entry of dyn.imports ?? []) { + const name = entryName(entry); + if (name === undefined) { + continue; + } + expect(seen.has(name)).toBe(false); + seen.add(name); + } + + for (const m of FEATURE_MODULES) { + const name = m?.name; + expect(name).toBeDefined(); + expect(seen.has(name as string)).toBe(true); + } + }); +}); + +describe('app.module.ts (Issue #256 - <80 LoC, single registry import)', () => { + // This spec lives in ``app/backend/test/`` so ``__dirname`` is the + // test directory and ``app.module.ts`` sits one level up at + // ``../src/app.module.ts``. + const appModulePath = path.join(__dirname, '..', 'src', 'app.module.ts'); + + it('is strictly under 80 lines of code', () => { + const text = fs.readFileSync(appModulePath, 'utf-8'); + const lines = text.split('\n'); + expect(lines.length).toBeLessThan(80); + }); + + it('imports the FeatureModuleRegistry so AppModule no longer wires modules by hand', () => { + const text = fs.readFileSync(appModulePath, 'utf-8'); + expect(text).toMatch(/FeatureModuleRegistry/); + // AppModule should no longer carry the inline infrastructure + // factories — they were extracted into the registry. + expect(text).not.toMatch(/BullModule\.forRootAsync/); + expect(text).not.toMatch(/ThrottlerModule\.forRoot/); + expect(text).not.toMatch(/RedisModule\.forRootAsync/); + expect(text).not.toMatch(/ConfigModule\.forRoot/); + }); +}); diff --git a/app/frontend/openapi.json b/app/frontend/openapi.json index 48fff834..3bbdcc78 100644 --- a/app/frontend/openapi.json +++ b/app/frontend/openapi.json @@ -210,87 +210,39 @@ ] } }, - "/api/v1/admin/ledger/backfill": { + "/api/v1/aid/campaigns": { "post": { - "description": "Start a backfill job to process a range of ledgers and populate missing ledger entries. Idempotent - can be run repeatedly without duplicating data.", - "operationId": "LedgerAdminController_triggerBackfill_v1", + "description": "Initializes a new aid campaign with provided metadata. Requires appropriate permissions.", + "operationId": "AidController_createCampaign_v1", "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "startLedger": { - "type": "number", - "description": "Starting ledger sequence number" - }, - "endLedger": { - "type": "number", - "description": "Ending ledger sequence number" - }, - "campaignId": { - "type": "string", - "description": "Optional campaign ID to filter" - }, - "batchSize": { - "type": "number", - "description": "Number of ledgers to process per batch (default: 100)" - } - }, - "required": [ - "startLedger", - "endLedger" - ] - } - } - } - }, "responses": { - "200": { - "description": "Backfill job queued successfully.", - "content": { - "application/json": { - "schema": { - "example": { - "jobId": "job_123", - "startLedger": 1000, - "endLedger": 2000, - "status": "queued", - "processedCount": 0, - "totalCount": 1001 - } - } - } - } + "201": { + "description": "Campaign created successfully." }, "400": { - "description": "Invalid request parameters." - }, - "401": { - "description": "Unauthorized - valid JWT token required." - }, - "403": { - "description": "Access denied - admin role required." + "description": "Invalid input parameters." } }, - "summary": "Trigger ledger backfill job", + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Create a new campaign", "tags": [ - "Ledger Admin" + "Aid" ] } }, - "/api/v1/admin/ledger/backfill/{jobId}": { - "get": { - "description": "Retrieve the current status of a backfill job.", - "operationId": "LedgerAdminController_getBackfillStatus_v1", + "/api/v1/aid/campaigns/{id}": { + "patch": { + "description": "Modifies an existing campaign. Only provided fields will be updated.", + "operationId": "AidController_updateCampaign_v1", "parameters": [ { - "name": "jobId", + "name": "id", "required": true, "in": "path", - "description": "Job ID returned from triggerBackfill", "schema": { "type": "string" } @@ -298,117 +250,66 @@ ], "responses": { "200": { - "description": "Backfill status retrieved successfully." + "description": "Campaign updated successfully." }, - "401": { - "description": "Unauthorized - valid JWT token required." + "400": { + "description": "Invalid update data." }, - "403": { - "description": "Access denied - admin role required." + "404": { + "description": "The specified campaign was not found." } }, - "summary": "Get backfill job status", + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Update a campaign", "tags": [ - "Ledger Admin" + "Aid" ] - } - }, - "/api/v1/admin/ledger/reconcile": { - "post": { - "description": "Start a reconciliation job to compare on-chain data against stored records and detect discrepancies.", - "operationId": "LedgerAdminController_triggerReconciliation_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "startLedger": { - "type": "number", - "description": "Starting ledger sequence number" - }, - "endLedger": { - "type": "number", - "description": "Ending ledger sequence number" - }, - "campaignId": { - "type": "string", - "description": "Optional campaign ID to filter" - }, - "thresholdPercent": { - "type": "number", - "description": "Threshold percentage for amount mismatch (default: 5)" - } - }, - "required": [ - "startLedger", - "endLedger" - ] - } + }, + "delete": { + "description": "Soft-archives a campaign, making it invisible to standard listings.", + "operationId": "AidController_archiveCampaign_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" } } - }, + ], "responses": { "200": { - "description": "Reconciliation job queued successfully.", - "content": { - "application/json": { - "schema": { - "example": { - "jobId": "job_456", - "startLedger": 1000, - "endLedger": 2000, - "status": "queued", - "totalLedgers": 1001, - "checkedLedgers": 0, - "discrepancies": [], - "summary": { - "totalDiscrepancies": 0, - "bySeverity": { - "low": 0, - "medium": 0, - "high": 0 - }, - "byType": { - "missing": 0, - "amount_mismatch": 0, - "count_mismatch": 0 - } - }, - "actionable": false - } - } - } - } - }, - "400": { - "description": "Invalid request parameters." - }, - "401": { - "description": "Unauthorized - valid JWT token required." + "description": "Campaign archived successfully." }, - "403": { - "description": "Access denied - admin role required." + "404": { + "description": "The specified campaign was not found." } }, - "summary": "Trigger ledger reconciliation job", + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Archive a campaign", "tags": [ - "Ledger Admin" + "Aid" ] } }, - "/api/v1/admin/ledger/reconcile/{jobId}": { - "get": { - "description": "Retrieve the current status and report of a reconciliation job.", - "operationId": "LedgerAdminController_getReconciliationStatus_v1", + "/api/v1/aid/claims/{id}/status": { + "put": { + "description": "Moves a claim from one status to another (e.g., pending -> approved).", + "operationId": "AidController_transitionClaim_v1", "parameters": [ { - "name": "jobId", + "name": "id", "required": true, "in": "path", - "description": "Job ID returned from triggerReconciliation", "schema": { "type": "string" } @@ -416,110 +317,157 @@ ], "responses": { "200": { - "description": "Reconciliation status retrieved successfully." + "description": "Claim status transitioned successfully." }, - "401": { - "description": "Unauthorized - valid JWT token required." + "400": { + "description": "Invalid status transition requested." }, - "403": { - "description": "Access denied - admin role required." + "404": { + "description": "The specified claim was not found." } }, - "summary": "Get reconciliation job status", + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Transition a claim status", "tags": [ - "Ledger Admin" + "Aid" ] } }, - "/api/v1/jobs/status": { - "get": { - "description": "Retrieves the count of waiting, active, completed, failed, and delayed jobs for all system queues.", - "operationId": "JobsController_getStatus_v1", + "/api/v1/aid/webhook": { + "post": { + "description": "Receives notifications from the AI service when background tasks complete.", + "operationId": "AidController_handleTaskWebhook_v1", "parameters": [], - "responses": { - "200": { - "description": "Queue statuses retrieved successfully.", - "content": { - "application/json": { - "schema": { - "example": { - "verification": { - "name": "verification", - "waiting": 0, - "active": 0, - "completed": 10, - "failed": 0, - "delayed": 0 - }, - "notifications": { - "name": "notifications", - "waiting": 0, - "active": 0, - "completed": 5, - "failed": 0, - "delayed": 0 - }, - "onchain": { - "name": "onchain", - "waiting": 0, - "active": 0, - "completed": 2, - "failed": 0, - "delayed": 0 - } - } - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AiTaskWebhookDto" } } } }, - "summary": "Get status of all background job queues", + "responses": { + "200": { + "description": "Webhook received successfully." + }, + "400": { + "description": "Invalid webhook payload." + } + }, + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Webhook for AI task notifications", "tags": [ - "Jobs" + "Aid" ] } }, - "/api/v1/jobs/health": { - "get": { - "description": "Checks if any core queues are degraded (too many waiting or failed jobs).", - "operationId": "JobsController_getHealth_v1", + "/api/v1/onchain/aid-escrow/packages": { + "post": { + "description": "Creates a new aid package with specified recipient, amount, and expiration. Only authorized operators can create packages.", + "operationId": "AidEscrowController_createAidPackage_v1", "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAidPackageDto" + } + } + } + }, "responses": { - "200": { - "description": "" + "201": { + "description": "Package created successfully.", + "content": { + "application/json": { + "schema": { + "example": { + "packageId": "pkg_123456789", + "transactionHash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", + "timestamp": "2026-03-30T12:30:00.000Z", + "status": "success", + "metadata": { + "contractId": "CBAA...", + "operator": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ" + } + } + } + } + } + }, + "400": { + "description": "Invalid input parameters." + }, + "500": { + "description": "Blockchain transaction failed." } }, - "summary": "Get overall health of background job queues", - "tags": [ - "Jobs" - ] - } - }, - "/api/v1/metrics": { - "get": { - "operationId": "PrometheusController_index_v1", - "parameters": [], - "responses": { - "200": { - "description": "" + "security": [ + { + "JWT-auth": [] } - }, + ], + "summary": "Create an aid package", "tags": [ - "Prometheus" + "Onchain - Aid Escrow" ] } }, - "/api/v1/aid/campaigns": { + "/api/v1/onchain/aid-escrow/packages/batch": { "post": { - "description": "Initializes a new aid campaign with provided metadata. Requires appropriate permissions.", - "operationId": "AidController_createCampaign_v1", + "description": "Creates multiple aid packages for multiple recipients in a single transaction. More efficient than individual creation.", + "operationId": "AidEscrowController_batchCreateAidPackages_v1", "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchCreateAidPackagesDto" + } + } + } + }, "responses": { "201": { - "description": "Campaign created successfully." + "description": "Packages created successfully.", + "content": { + "application/json": { + "schema": { + "example": { + "packageIds": [ + "0", + "1", + "2" + ], + "transactionHash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", + "timestamp": "2026-03-30T12:30:00.000Z", + "status": "success", + "metadata": { + "contractId": "CBAA...", + "count": 3 + } + } + } + } + } }, "400": { - "description": "Invalid input parameters." + "description": "Invalid input or mismatched arrays." + }, + "500": { + "description": "Blockchain transaction failed." } }, "security": [ @@ -527,16 +475,16 @@ "JWT-auth": [] } ], - "summary": "Create a new campaign", + "summary": "Batch create aid packages", "tags": [ - "Aid" + "Onchain - Aid Escrow" ] } }, - "/api/v1/aid/campaigns/{id}": { - "patch": { - "description": "Modifies an existing campaign. Only provided fields will be updated.", - "operationId": "AidController_updateCampaign_v1", + "/api/v1/onchain/aid-escrow/packages/{id}/claim": { + "post": { + "description": "Claims an aid package as the recipient, transferring the funds to their wallet. Can only be claimed once.", + "operationId": "AidEscrowController_claimAidPackage_v1", "parameters": [ { "name": "id", @@ -549,13 +497,33 @@ ], "responses": { "200": { - "description": "Campaign updated successfully." + "description": "Package claimed successfully.", + "content": { + "application/json": { + "schema": { + "example": { + "packageId": "pkg_123456789", + "transactionHash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", + "timestamp": "2026-03-30T12:30:00.000Z", + "status": "success", + "amountClaimed": "1000000000", + "metadata": { + "contractId": "CBAA...", + "recipient": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ" + } + } + } + } + } }, "400": { - "description": "Invalid update data." + "description": "Package not found or not claimable." }, "404": { - "description": "The specified campaign was not found." + "description": "Package does not exist." + }, + "500": { + "description": "Blockchain transaction failed." } }, "security": [ @@ -563,14 +531,16 @@ "JWT-auth": [] } ], - "summary": "Update a campaign", + "summary": "Claim an aid package", "tags": [ - "Aid" + "Onchain - Aid Escrow" ] - }, - "delete": { - "description": "Soft-archives a campaign, making it invisible to standard listings.", - "operationId": "AidController_archiveCampaign_v1", + } + }, + "/api/v1/onchain/aid-escrow/packages/{id}/disburse": { + "post": { + "description": "Disburses an aid package from the admin/operator, transferring funds to the recipient. Admin-only action.", + "operationId": "AidEscrowController_disburseAidPackage_v1", "parameters": [ { "name": "id", @@ -583,10 +553,33 @@ ], "responses": { "200": { - "description": "Campaign archived successfully." + "description": "Package disbursed successfully.", + "content": { + "application/json": { + "schema": { + "example": { + "packageId": "pkg_123456789", + "transactionHash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", + "timestamp": "2026-03-30T12:30:00.000Z", + "status": "success", + "amountDisbursed": "1000000000", + "metadata": { + "contractId": "CBAA...", + "operator": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ" + } + } + } + } + } + }, + "400": { + "description": "Package not found or not disbursable." }, "404": { - "description": "The specified campaign was not found." + "description": "Package does not exist." + }, + "500": { + "description": "Blockchain transaction failed." } }, "security": [ @@ -594,16 +587,16 @@ "JWT-auth": [] } ], - "summary": "Archive a campaign", + "summary": "Disburse an aid package", "tags": [ - "Aid" + "Onchain - Aid Escrow" ] } }, - "/api/v1/aid/claims/{id}/status": { - "put": { - "description": "Moves a claim from one status to another (e.g., pending -> approved).", - "operationId": "AidController_transitionClaim_v1", + "/api/v1/onchain/aid-escrow/packages/{id}": { + "get": { + "description": "Retrieves the full details of an aid package including status, amount, and expiration.", + "operationId": "AidEscrowController_getAidPackage_v1", "parameters": [ { "name": "id", @@ -616,13 +609,34 @@ ], "responses": { "200": { - "description": "Claim status transitioned successfully." - }, - "400": { - "description": "Invalid status transition requested." + "description": "Package details retrieved successfully.", + "content": { + "application/json": { + "schema": { + "example": { + "package": { + "id": "pkg_123456789", + "recipient": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ", + "amount": "1000000000", + "token": "GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN", + "status": "Created", + "createdAt": 1711814400, + "expiresAt": 1714406400, + "metadata": { + "campaign_ref": "campaign-123" + } + }, + "timestamp": "2026-03-30T12:30:00.000Z" + } + } + } + } }, "404": { - "description": "The specified claim was not found." + "description": "Package not found." + }, + "500": { + "description": "Failed to retrieve package." } }, "security": [ @@ -630,33 +644,91 @@ "JWT-auth": [] } ], - "summary": "Transition a claim status", + "summary": "Get aid package details", "tags": [ - "Aid" + "Onchain - Aid Escrow" ] } }, - "/api/v1/aid/webhook": { - "post": { - "description": "Receives notifications from the AI service when background tasks complete.", - "operationId": "AidController_handleTaskWebhook_v1", + "/api/v1/onchain/aid-escrow/stats": { + "get": { + "description": "Retrieves aggregated statistics for aid packages by token, including total committed, claimed, and expired amounts.", + "operationId": "AidEscrowController_getAidPackageStats_v1", "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AiTaskWebhookDto" + "responses": { + "200": { + "description": "Statistics retrieved successfully.", + "content": { + "application/json": { + "schema": { + "example": { + "aggregates": { + "totalCommitted": "5000000000", + "totalClaimed": "2000000000", + "totalExpiredCancelled": "500000000" + }, + "timestamp": "2026-03-30T12:30:00.000Z" + } + } } } + }, + "400": { + "description": "Invalid token address." + }, + "500": { + "description": "Failed to retrieve statistics." + } + }, + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Get aid package statistics", + "tags": [ + "Onchain - Aid Escrow" + ] + } + }, + "/api/v1/onchain/aid-escrow/transactions/{hash}/status": { + "get": { + "description": "Polls Soroban RPC for the status of a transaction by its hash. Returns a normalized status: pending, succeeded, failed, or unknown.", + "operationId": "AidEscrowController_getTransactionStatus_v1", + "parameters": [ + { + "name": "hash", + "required": true, + "in": "path", + "schema": { + "type": "string" + } } - }, + ], "responses": { "200": { - "description": "Webhook received successfully." + "description": "Transaction status retrieved successfully.", + "content": { + "application/json": { + "schema": { + "example": { + "hash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", + "status": "succeeded", + "timestamp": "2026-03-30T12:30:00.000Z", + "ledger": 12345 + } + } + } + } }, "400": { - "description": "Invalid webhook payload." + "description": "Invalid transaction hash." + }, + "404": { + "description": "Transaction not found." + }, + "500": { + "description": "Failed to retrieve transaction status." } }, "security": [ @@ -664,9 +736,9 @@ "JWT-auth": [] } ], - "summary": "Webhook for AI task notifications", + "summary": "Get transaction status", "tags": [ - "Aid" + "Onchain - Aid Escrow" ] } }, @@ -1928,72 +2000,6 @@ ] } }, - "/api/v1/notifications/outbox": { - "get": { - "description": "Returns all NotificationOutbox records in pending or enqueued status whose scheduledFor is more than 10 minutes in the past.", - "operationId": "OutboxController_listStuck_v1", - "parameters": [], - "responses": { - "200": { - "description": "Stuck outbox records returned." - }, - "401": { - "description": "Missing or invalid API key." - }, - "403": { - "description": "Insufficient role (requires admin or operator)." - } - }, - "security": [ - { - "JWT-auth": [] - } - ], - "summary": "List stuck notification outbox records", - "tags": [ - "Notifications Outbox" - ] - } - }, - "/api/v1/notifications/outbox/{id}": { - "get": { - "description": "Returns the NotificationOutbox record for the given id.", - "operationId": "OutboxController_getOne_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Outbox record returned." - }, - "401": { - "description": "Missing or invalid API key." - }, - "403": { - "description": "Insufficient role (requires admin or operator)." - }, - "404": { - "description": "Outbox record not found." - } - }, - "security": [ - { - "JWT-auth": [] - } - ], - "summary": "Get a single notification outbox record", - "tags": [ - "Notifications Outbox" - ] - } - }, "/api/v1/csp-report": { "post": { "description": "Endpoint to receive and log Content Security Policy violations.", @@ -3139,319 +3145,114 @@ } } } - }, - "responses": { - "200": { - "description": "Receipt generated and sharing initiated successfully.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClaimShareResponseDto" - } - } - } - }, - "400": { - "description": "Invalid share parameters." - }, - "404": { - "description": "The specified claim was not found." - } - }, - "security": [ - { - "JWT-auth": [] - } - ], - "summary": "Share claim receipt", - "tags": [ - "Onchain Proxy" - ] - } - }, - "/api/v1/claims/export": { - "get": { - "description": "Exports claim records as CSV with support for date range, status, organization, token, and pagination filters. Excludes sensitive recipient data (recipientRef is encrypted and not exported).", - "operationId": "ClaimsController_exportClaims_v1", - "parameters": [ - { - "name": "limit", - "required": false, - "in": "query", - "description": "Items per page (default: 50, max: 200)", - "schema": {} - }, - { - "name": "page", - "required": false, - "in": "query", - "description": "Page number (default: 1)", - "schema": {} - }, - { - "name": "tokenAddress", - "required": false, - "in": "query", - "description": "Token address filter", - "schema": {} - }, - { - "name": "orgId", - "required": false, - "in": "query", - "description": "Organization ID filter", - "schema": {} - }, - { - "name": "campaignId", - "required": false, - "in": "query", - "description": "Campaign ID filter", - "schema": {} - }, - { - "name": "status", - "required": false, - "in": "query", - "description": "Claim status filter", - "schema": {} - }, - { - "name": "to", - "required": false, - "in": "query", - "description": "End date (ISO string)", - "schema": {} - }, - { - "name": "from", - "required": false, - "in": "query", - "description": "Start date (ISO string)", - "schema": {} - } - ], - "responses": { - "200": { - "description": "Claims exported successfully.", - "content": { - "text/csv": { - "schema": { - "type": "string" - } - } - } - }, - "401": { - "description": "Missing or invalid authentication credentials." - }, - "403": { - "description": "Access denied - operator or admin role required." - } - }, - "security": [ - { - "JWT-auth": [] - } - ], - "summary": "Export claims as CSV", - "tags": [ - "Onchain Proxy" - ] - } - }, - "/api/v1/analytics/global-stats": { - "get": { - "description": "Returns aggregated totals and breakdowns by token and region for the dashboard.", - "operationId": "AnalyticsController_getGlobalStats_v1", - "parameters": [ - { - "name": "from", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "to", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "region", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "token", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - } - ], + }, "responses": { "200": { - "description": "Global statistics retrieved successfully.", + "description": "Receipt generated and sharing initiated successfully.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GlobalStatsDto" + "$ref": "#/components/schemas/ClaimShareResponseDto" } } } + }, + "400": { + "description": "Invalid share parameters." + }, + "404": { + "description": "The specified claim was not found." } }, - "summary": "Get global distribution statistics", + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Share claim receipt", "tags": [ - "Analytics" + "Onchain Proxy" ] } }, - "/api/v1/analytics/map-data": { + "/api/v1/claims/export": { "get": { - "description": "Returns a list of distribution points with coordinates and amounts for map visualization.", - "operationId": "AnalyticsController_getMapData_v1", + "description": "Exports claim records as CSV with support for date range, status, organization, token, and pagination filters. Excludes sensitive recipient data (recipientRef is encrypted and not exported).", + "operationId": "ClaimsController_exportClaims_v1", "parameters": [ { - "name": "region", + "name": "limit", "required": false, "in": "query", - "schema": { - "type": "string" - } + "description": "Items per page (default: 50, max: 200)", + "schema": {} }, { - "name": "token", + "name": "page", "required": false, "in": "query", - "schema": { - "type": "string" - } + "description": "Page number (default: 1)", + "schema": {} }, { - "name": "status", + "name": "tokenAddress", "required": false, "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Map data points retrieved successfully.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MapDataDto" - } - } - } - } - }, - "summary": "Get map data points", - "tags": [ - "Analytics" - ] - } - }, - "/api/v1/analytics/map-anonymized": { - "get": { - "description": "Returns distribution data in GeoJSON format with anonymized locations for privacy.", - "operationId": "AnalyticsController_getMapAnonymizedData_v1", - "parameters": [ + "description": "Token address filter", + "schema": {} + }, { - "name": "region", + "name": "orgId", "required": false, "in": "query", - "schema": { - "type": "string" - } + "description": "Organization ID filter", + "schema": {} }, { - "name": "token", + "name": "campaignId", "required": false, "in": "query", - "schema": { - "type": "string" - } + "description": "Campaign ID filter", + "schema": {} }, { "name": "status", "required": false, "in": "query", - "schema": { - "type": "string" - } + "description": "Claim status filter", + "schema": {} + }, + { + "name": "to", + "required": false, + "in": "query", + "description": "End date (ISO string)", + "schema": {} + }, + { + "name": "from", + "required": false, + "in": "query", + "description": "Start date (ISO string)", + "schema": {} } ], "responses": { "200": { - "description": "Anonymized GeoJSON data retrieved successfully.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GeoJsonFeatureCollection" - } - } - } - } - }, - "summary": "Get anonymized map data (GeoJSON)", - "tags": [ - "Analytics" - ] - } - }, - "/api/v1/onchain/aid-escrow/packages": { - "post": { - "description": "Creates a new aid package with specified recipient, amount, and expiration. Only authorized operators can create packages.", - "operationId": "AidEscrowController_createAidPackage_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAidPackageDto" - } - } - } - }, - "responses": { - "201": { - "description": "Package created successfully.", + "description": "Claims exported successfully.", "content": { - "application/json": { + "text/csv": { "schema": { - "example": { - "packageId": "pkg_123456789", - "transactionHash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", - "timestamp": "2026-03-30T12:30:00.000Z", - "status": "success", - "metadata": { - "contractId": "CBAA...", - "operator": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ" - } - } + "type": "string" } } } }, - "400": { - "description": "Invalid input parameters." + "401": { + "description": "Missing or invalid authentication credentials." }, - "500": { - "description": "Blockchain transaction failed." + "403": { + "description": "Access denied - operator or admin role required." } }, "security": [ @@ -3459,56 +3260,26 @@ "JWT-auth": [] } ], - "summary": "Create an aid package", + "summary": "Export claims as CSV", "tags": [ - "Onchain - Aid Escrow" + "Onchain Proxy" ] } }, - "/api/v1/onchain/aid-escrow/packages/batch": { - "post": { - "description": "Creates multiple aid packages for multiple recipients in a single transaction. More efficient than individual creation.", - "operationId": "AidEscrowController_batchCreateAidPackages_v1", + "/api/v1/notifications/outbox": { + "get": { + "description": "Returns all NotificationOutbox records in pending or enqueued status whose scheduledFor is more than 10 minutes in the past.", + "operationId": "OutboxController_listStuck_v1", "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchCreateAidPackagesDto" - } - } - } - }, "responses": { - "201": { - "description": "Packages created successfully.", - "content": { - "application/json": { - "schema": { - "example": { - "packageIds": [ - "0", - "1", - "2" - ], - "transactionHash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", - "timestamp": "2026-03-30T12:30:00.000Z", - "status": "success", - "metadata": { - "contractId": "CBAA...", - "count": 3 - } - } - } - } - } + "200": { + "description": "Stuck outbox records returned." }, - "400": { - "description": "Invalid input or mismatched arrays." + "401": { + "description": "Missing or invalid API key." }, - "500": { - "description": "Blockchain transaction failed." + "403": { + "description": "Insufficient role (requires admin or operator)." } }, "security": [ @@ -3516,16 +3287,16 @@ "JWT-auth": [] } ], - "summary": "Batch create aid packages", + "summary": "List stuck notification outbox records", "tags": [ - "Onchain - Aid Escrow" + "Notifications Outbox" ] } }, - "/api/v1/onchain/aid-escrow/packages/{id}/claim": { - "post": { - "description": "Claims an aid package as the recipient, transferring the funds to their wallet. Can only be claimed once.", - "operationId": "AidEscrowController_claimAidPackage_v1", + "/api/v1/notifications/outbox/{id}": { + "get": { + "description": "Returns the NotificationOutbox record for the given id.", + "operationId": "OutboxController_getOne_v1", "parameters": [ { "name": "id", @@ -3538,33 +3309,16 @@ ], "responses": { "200": { - "description": "Package claimed successfully.", - "content": { - "application/json": { - "schema": { - "example": { - "packageId": "pkg_123456789", - "transactionHash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", - "timestamp": "2026-03-30T12:30:00.000Z", - "status": "success", - "amountClaimed": "1000000000", - "metadata": { - "contractId": "CBAA...", - "recipient": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ" - } - } - } - } - } + "description": "Outbox record returned." }, - "400": { - "description": "Package not found or not claimable." + "401": { + "description": "Missing or invalid API key." }, - "404": { - "description": "Package does not exist." + "403": { + "description": "Insufficient role (requires admin or operator)." }, - "500": { - "description": "Blockchain transaction failed." + "404": { + "description": "Outbox record not found." } }, "security": [ @@ -3572,77 +3326,109 @@ "JWT-auth": [] } ], - "summary": "Claim an aid package", + "summary": "Get a single notification outbox record", "tags": [ - "Onchain - Aid Escrow" + "Notifications Outbox" ] } }, - "/api/v1/onchain/aid-escrow/packages/{id}/disburse": { - "post": { - "description": "Disburses an aid package from the admin/operator, transferring funds to the recipient. Admin-only action.", - "operationId": "AidEscrowController_disburseAidPackage_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "/api/v1/jobs/status": { + "get": { + "description": "Retrieves the count of waiting, active, completed, failed, and delayed jobs for all system queues.", + "operationId": "JobsController_getStatus_v1", + "parameters": [], "responses": { "200": { - "description": "Package disbursed successfully.", + "description": "Queue statuses retrieved successfully.", "content": { "application/json": { "schema": { "example": { - "packageId": "pkg_123456789", - "transactionHash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", - "timestamp": "2026-03-30T12:30:00.000Z", - "status": "success", - "amountDisbursed": "1000000000", - "metadata": { - "contractId": "CBAA...", - "operator": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ" + "verification": { + "name": "verification", + "waiting": 0, + "active": 0, + "completed": 10, + "failed": 0, + "delayed": 0 + }, + "notifications": { + "name": "notifications", + "waiting": 0, + "active": 0, + "completed": 5, + "failed": 0, + "delayed": 0 + }, + "onchain": { + "name": "onchain", + "waiting": 0, + "active": 0, + "completed": 2, + "failed": 0, + "delayed": 0 } } } } } - }, - "400": { - "description": "Package not found or not disbursable." - }, - "404": { - "description": "Package does not exist." - }, - "500": { - "description": "Blockchain transaction failed." } }, - "security": [ - { - "JWT-auth": [] + "summary": "Get status of all background job queues", + "tags": [ + "Jobs" + ] + } + }, + "/api/v1/jobs/health": { + "get": { + "description": "Checks if any core queues are degraded (too many waiting or failed jobs).", + "operationId": "JobsController_getHealth_v1", + "parameters": [], + "responses": { + "200": { + "description": "" } - ], - "summary": "Disburse an aid package", + }, + "summary": "Get overall health of background job queues", "tags": [ - "Onchain - Aid Escrow" + "Jobs" ] } }, - "/api/v1/onchain/aid-escrow/packages/{id}": { + "/api/v1/analytics/global-stats": { "get": { - "description": "Retrieves the full details of an aid package including status, amount, and expiration.", - "operationId": "AidEscrowController_getAidPackage_v1", + "description": "Returns aggregated totals and breakdowns by token and region for the dashboard.", + "operationId": "AnalyticsController_getGlobalStats_v1", "parameters": [ { - "name": "id", - "required": true, - "in": "path", + "name": "from", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "region", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "token", + "required": false, + "in": "query", "schema": { "type": "string" } @@ -3650,97 +3436,95 @@ ], "responses": { "200": { - "description": "Package details retrieved successfully.", + "description": "Global statistics retrieved successfully.", "content": { "application/json": { "schema": { - "example": { - "package": { - "id": "pkg_123456789", - "recipient": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ", - "amount": "1000000000", - "token": "GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN", - "status": "Created", - "createdAt": 1711814400, - "expiresAt": 1714406400, - "metadata": { - "campaign_ref": "campaign-123" - } - }, - "timestamp": "2026-03-30T12:30:00.000Z" - } + "$ref": "#/components/schemas/GlobalStatsDto" } } } - }, - "404": { - "description": "Package not found." - }, - "500": { - "description": "Failed to retrieve package." } }, - "security": [ - { - "JWT-auth": [] - } - ], - "summary": "Get aid package details", + "summary": "Get global distribution statistics", "tags": [ - "Onchain - Aid Escrow" + "Analytics" ] } }, - "/api/v1/onchain/aid-escrow/stats": { + "/api/v1/analytics/map-data": { "get": { - "description": "Retrieves aggregated statistics for aid packages by token, including total committed, claimed, and expired amounts.", - "operationId": "AidEscrowController_getAidPackageStats_v1", - "parameters": [], + "description": "Returns a list of distribution points with coordinates and amounts for map visualization.", + "operationId": "AnalyticsController_getMapData_v1", + "parameters": [ + { + "name": "region", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "token", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "Statistics retrieved successfully.", + "description": "Map data points retrieved successfully.", "content": { "application/json": { "schema": { - "example": { - "aggregates": { - "totalCommitted": "5000000000", - "totalClaimed": "2000000000", - "totalExpiredCancelled": "500000000" - }, - "timestamp": "2026-03-30T12:30:00.000Z" - } + "$ref": "#/components/schemas/MapDataDto" } } } - }, - "400": { - "description": "Invalid token address." - }, - "500": { - "description": "Failed to retrieve statistics." } }, - "security": [ - { - "JWT-auth": [] - } - ], - "summary": "Get aid package statistics", + "summary": "Get map data points", "tags": [ - "Onchain - Aid Escrow" + "Analytics" ] } }, - "/api/v1/onchain/aid-escrow/transactions/{hash}/status": { + "/api/v1/analytics/map-anonymized": { "get": { - "description": "Polls Soroban RPC for the status of a transaction by its hash. Returns a normalized status: pending, succeeded, failed, or unknown.", - "operationId": "AidEscrowController_getTransactionStatus_v1", + "description": "Returns distribution data in GeoJSON format with anonymized locations for privacy.", + "operationId": "AnalyticsController_getMapAnonymizedData_v1", "parameters": [ { - "name": "hash", - "required": true, - "in": "path", + "name": "region", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "token", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "required": false, + "in": "query", "schema": { "type": "string" } @@ -3748,38 +3532,19 @@ ], "responses": { "200": { - "description": "Transaction status retrieved successfully.", + "description": "Anonymized GeoJSON data retrieved successfully.", "content": { "application/json": { "schema": { - "example": { - "hash": "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABCD", - "status": "succeeded", - "timestamp": "2026-03-30T12:30:00.000Z", - "ledger": 12345 - } + "$ref": "#/components/schemas/GeoJsonFeatureCollection" } } } - }, - "400": { - "description": "Invalid transaction hash." - }, - "404": { - "description": "Transaction not found." - }, - "500": { - "description": "Failed to retrieve transaction status." } }, - "security": [ - { - "JWT-auth": [] - } - ], - "summary": "Get transaction status", + "summary": "Get anonymized map data (GeoJSON)", "tags": [ - "Onchain - Aid Escrow" + "Analytics" ] } }, @@ -5307,53 +5072,288 @@ } }, "tags": [ - "Sandbox" + "Sandbox" + ] + } + }, + "/api/v1/admin/sandbox/seed/campaigns": { + "post": { + "operationId": "SandboxController_seedCampaigns_v1", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Sandbox" + ] + } + }, + "/api/v1/admin/sandbox/seed/claims": { + "post": { + "operationId": "SandboxController_seedClaims_v1", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Sandbox" + ] + } + }, + "/api/v1/admin/sandbox/seed": { + "post": { + "operationId": "SandboxController_seedAll_v1", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Sandbox" + ] + }, + "delete": { + "operationId": "SandboxController_resetSeed_v1", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Sandbox" + ] + } + }, + "/api/v1/admin/ledger/backfill": { + "post": { + "description": "Start a backfill job to process a range of ledgers and populate missing ledger entries. Idempotent - can be run repeatedly without duplicating data.", + "operationId": "LedgerAdminController_triggerBackfill_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "startLedger": { + "type": "number", + "description": "Starting ledger sequence number" + }, + "endLedger": { + "type": "number", + "description": "Ending ledger sequence number" + }, + "campaignId": { + "type": "string", + "description": "Optional campaign ID to filter" + }, + "batchSize": { + "type": "number", + "description": "Number of ledgers to process per batch (default: 100)" + } + }, + "required": [ + "startLedger", + "endLedger" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Backfill job queued successfully.", + "content": { + "application/json": { + "schema": { + "example": { + "jobId": "job_123", + "startLedger": 1000, + "endLedger": 2000, + "status": "queued", + "processedCount": 0, + "totalCount": 1001 + } + } + } + } + }, + "400": { + "description": "Invalid request parameters." + }, + "401": { + "description": "Unauthorized - valid JWT token required." + }, + "403": { + "description": "Access denied - admin role required." + } + }, + "summary": "Trigger ledger backfill job", + "tags": [ + "Ledger Admin" + ] + } + }, + "/api/v1/admin/ledger/backfill/{jobId}": { + "get": { + "description": "Retrieve the current status of a backfill job.", + "operationId": "LedgerAdminController_getBackfillStatus_v1", + "parameters": [ + { + "name": "jobId", + "required": true, + "in": "path", + "description": "Job ID returned from triggerBackfill", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Backfill status retrieved successfully." + }, + "401": { + "description": "Unauthorized - valid JWT token required." + }, + "403": { + "description": "Access denied - admin role required." + } + }, + "summary": "Get backfill job status", + "tags": [ + "Ledger Admin" + ] + } + }, + "/api/v1/admin/ledger/reconcile": { + "post": { + "description": "Start a reconciliation job to compare on-chain data against stored records and detect discrepancies.", + "operationId": "LedgerAdminController_triggerReconciliation_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "startLedger": { + "type": "number", + "description": "Starting ledger sequence number" + }, + "endLedger": { + "type": "number", + "description": "Ending ledger sequence number" + }, + "campaignId": { + "type": "string", + "description": "Optional campaign ID to filter" + }, + "thresholdPercent": { + "type": "number", + "description": "Threshold percentage for amount mismatch (default: 5)" + } + }, + "required": [ + "startLedger", + "endLedger" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Reconciliation job queued successfully.", + "content": { + "application/json": { + "schema": { + "example": { + "jobId": "job_456", + "startLedger": 1000, + "endLedger": 2000, + "status": "queued", + "totalLedgers": 1001, + "checkedLedgers": 0, + "discrepancies": [], + "summary": { + "totalDiscrepancies": 0, + "bySeverity": { + "low": 0, + "medium": 0, + "high": 0 + }, + "byType": { + "missing": 0, + "amount_mismatch": 0, + "count_mismatch": 0 + } + }, + "actionable": false + } + } + } + } + }, + "400": { + "description": "Invalid request parameters." + }, + "401": { + "description": "Unauthorized - valid JWT token required." + }, + "403": { + "description": "Access denied - admin role required." + } + }, + "summary": "Trigger ledger reconciliation job", + "tags": [ + "Ledger Admin" ] } }, - "/api/v1/admin/sandbox/seed/campaigns": { - "post": { - "operationId": "SandboxController_seedCampaigns_v1", - "parameters": [], - "responses": { - "201": { - "description": "" + "/api/v1/admin/ledger/reconcile/{jobId}": { + "get": { + "description": "Retrieve the current status and report of a reconciliation job.", + "operationId": "LedgerAdminController_getReconciliationStatus_v1", + "parameters": [ + { + "name": "jobId", + "required": true, + "in": "path", + "description": "Job ID returned from triggerReconciliation", + "schema": { + "type": "string" + } } - }, - "tags": [ - "Sandbox" - ] - } - }, - "/api/v1/admin/sandbox/seed/claims": { - "post": { - "operationId": "SandboxController_seedClaims_v1", - "parameters": [], + ], "responses": { - "201": { - "description": "" + "200": { + "description": "Reconciliation status retrieved successfully." + }, + "401": { + "description": "Unauthorized - valid JWT token required." + }, + "403": { + "description": "Access denied - admin role required." } }, + "summary": "Get reconciliation job status", "tags": [ - "Sandbox" + "Ledger Admin" ] } }, - "/api/v1/admin/sandbox/seed": { - "post": { - "operationId": "SandboxController_seedAll_v1", - "parameters": [], - "responses": { - "201": { - "description": "" - } - }, - "tags": [ - "Sandbox" - ] - }, - "delete": { - "operationId": "SandboxController_resetSeed_v1", + "/api/v1/metrics": { + "get": { + "operationId": "PrometheusController_index_v1", "parameters": [], "responses": { "200": { @@ -5361,7 +5361,7 @@ } }, "tags": [ - "Sandbox" + "Prometheus" ] } } @@ -5465,6 +5465,98 @@ "status" ] }, + "CreateAidPackageDto": { + "type": "object", + "properties": { + "packageId": { + "type": "string", + "description": "Unique identifier for the package", + "example": "pkg_123456789" + }, + "recipientAddress": { + "type": "string", + "description": "Stellar address of the aid recipient", + "example": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ" + }, + "amount": { + "type": "string", + "description": "Amount in stroops (i128 as string to preserve precision)", + "example": "1000000000" + }, + "tokenAddress": { + "type": "string", + "description": "Stellar token address", + "example": "GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN" + }, + "expiresAt": { + "type": "number", + "description": "Unix timestamp when the package expires", + "example": 1704067200 + }, + "metadata": { + "type": "object", + "description": "Optional metadata as key-value pairs", + "example": { + "campaign_ref": "campaign-123", + "region": "LATAM" + } + } + }, + "required": [ + "packageId", + "recipientAddress", + "amount", + "tokenAddress", + "expiresAt" + ] + }, + "BatchCreateAidPackagesDto": { + "type": "object", + "properties": { + "recipientAddresses": { + "description": "Array of recipient Stellar addresses", + "example": [ + "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ", + "GA5ZSEJYB37JRC5AVCIA5MOP4GZ5DA47EL5QRUVLYEK2OOABEXVR5CV7" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "amounts": { + "description": "Array of amounts (in stroops, as strings)", + "example": [ + "1000000000", + "500000000" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "tokenAddress": { + "type": "string", + "description": "Stellar token address", + "example": "GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN" + }, + "expiresIn": { + "type": "number", + "description": "Duration in seconds from now until expiration", + "example": 2592000 + }, + "metadata": { + "type": "object", + "description": "Optional metadata as key-value pairs" + } + }, + "required": [ + "recipientAddresses", + "amounts", + "tokenAddress", + "expiresIn" + ] + }, "StartVerificationDto": { "type": "object", "properties": { @@ -6126,98 +6218,6 @@ "computedAt" ] }, - "CreateAidPackageDto": { - "type": "object", - "properties": { - "packageId": { - "type": "string", - "description": "Unique identifier for the package", - "example": "pkg_123456789" - }, - "recipientAddress": { - "type": "string", - "description": "Stellar address of the aid recipient", - "example": "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ" - }, - "amount": { - "type": "string", - "description": "Amount in stroops (i128 as string to preserve precision)", - "example": "1000000000" - }, - "tokenAddress": { - "type": "string", - "description": "Stellar token address", - "example": "GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN" - }, - "expiresAt": { - "type": "number", - "description": "Unix timestamp when the package expires", - "example": 1704067200 - }, - "metadata": { - "type": "object", - "description": "Optional metadata as key-value pairs", - "example": { - "campaign_ref": "campaign-123", - "region": "LATAM" - } - } - }, - "required": [ - "packageId", - "recipientAddress", - "amount", - "tokenAddress", - "expiresAt" - ] - }, - "BatchCreateAidPackagesDto": { - "type": "object", - "properties": { - "recipientAddresses": { - "description": "Array of recipient Stellar addresses", - "example": [ - "GBUQWP3BOUZX34ULNQG23RQ6F4BFXWBTRSE53XSTE23JMCVOCJGXVSVZ", - "GA5ZSEJYB37JRC5AVCIA5MOP4GZ5DA47EL5QRUVLYEK2OOABEXVR5CV7" - ], - "type": "array", - "items": { - "type": "string" - } - }, - "amounts": { - "description": "Array of amounts (in stroops, as strings)", - "example": [ - "1000000000", - "500000000" - ], - "type": "array", - "items": { - "type": "string" - } - }, - "tokenAddress": { - "type": "string", - "description": "Stellar token address", - "example": "GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN" - }, - "expiresIn": { - "type": "number", - "description": "Duration in seconds from now until expiration", - "example": 2592000 - }, - "metadata": { - "type": "object", - "description": "Optional metadata as key-value pairs" - } - }, - "required": [ - "recipientAddresses", - "amounts", - "tokenAddress", - "expiresIn" - ] - }, "CreateApiKeyDto": { "type": "object", "properties": {