From 2540f67d3c44cb8570907360163433c85667649f Mon Sep 17 00:00:00 2001 From: Raf Agent Date: Wed, 24 Jun 2026 18:17:48 +0800 Subject: [PATCH] feat: add raf-lens payment workbench expansion --- .prettierignore | 1 + README.md | 18 +- apps/payment-intelligence-pilot/.gitignore | 7 + apps/payment-intelligence-pilot/Makefile | 21 ++ apps/payment-intelligence-pilot/README.md | 137 +++++++++ .../app/__init__.py | 5 + .../app/api/__init__.py | 10 + .../app/api/analyses.py | 73 +++++ .../app/api/approvals.py | 44 +++ .../app/api/audit.py | 17 ++ .../app/api/connectors.py | 53 ++++ .../app/api/dashboard.py | 48 ++++ .../app/api/health.py | 43 +++ .../app/api/review.py | 43 +++ apps/payment-intelligence-pilot/app/config.py | 31 +++ apps/payment-intelligence-pilot/app/db.py | 66 +++++ apps/payment-intelligence-pilot/app/deps.py | 39 +++ apps/payment-intelligence-pilot/app/errors.py | 21 ++ apps/payment-intelligence-pilot/app/main.py | 60 ++++ apps/payment-intelligence-pilot/app/models.py | 109 ++++++++ .../payment-intelligence-pilot/app/schemas.py | 56 ++++ apps/payment-intelligence-pilot/app/seed.py | 81 ++++++ .../app/services/__init__.py | 0 .../app/services/approvals.py | 97 +++++++ .../app/services/audit.py | 52 ++++ .../app/services/connectors/__init__.py | 18 ++ .../app/services/connectors/profiles.py | 164 +++++++++++ .../app/services/connectors/registry.py | 169 ++++++++++++ .../app/services/readiness.py | 61 ++++ .../app/services/review.py | 89 ++++++ .../payment-intelligence-pilot/pyproject.toml | 28 ++ .../tests/conftest.py | 28 ++ .../tests/test_analyses_privacy.py | 54 ++++ .../tests/test_approvals_four_eyes.py | 44 +++ .../tests/test_audit_immutability.py | 38 +++ .../tests/test_auth_stub.py | 39 +++ .../tests/test_connector_disabled_guard.py | 32 +++ .../tests/test_connector_registry.py | 48 ++++ .../tests/test_health.py | 20 ++ .../tests/test_operator_summary.py | 41 +++ e2e/workbench.spec.ts | 56 ++++ src/App.tsx | 47 +++- src/App.workbench.test.tsx | 36 +++ src/components/layout/SuiteFooter.tsx | 34 ++- src/components/layout/SuiteHeader.tsx | 2 + src/components/workbench/CapabilityBadge.tsx | 38 +++ src/components/workbench/CaveatPanel.tsx | 36 +++ src/components/workbench/FindingsList.tsx | 36 +++ src/components/workbench/GateChecklist.tsx | 31 +++ src/components/workbench/MaturityLegend.tsx | 19 ++ src/components/workbench/ProvenanceCard.tsx | 68 +++++ src/lib/workbench/analytics.test.ts | 52 ++++ src/lib/workbench/analytics.ts | 87 ++++++ src/lib/workbench/connectors.test.ts | 37 +++ src/lib/workbench/connectors.ts | 216 +++++++++++++++ src/lib/workbench/index.ts | 8 + src/lib/workbench/review.test.ts | 45 +++ src/lib/workbench/review.ts | 55 ++++ src/lib/workbench/scenarios.test.ts | 31 +++ src/lib/workbench/scenarios.ts | 204 ++++++++++++++ src/lib/workbench/session/SessionContext.ts | 35 +++ src/lib/workbench/session/SessionProvider.tsx | 77 ++++++ src/lib/workbench/session/index.ts | 4 + src/lib/workbench/session/useSession.ts | 19 ++ src/lib/workbench/surfaces.ts | 240 ++++++++++++++++ src/lib/workbench/taxonomy.test.ts | 32 +++ src/lib/workbench/taxonomy.ts | 65 +++++ src/lib/workbench/types.ts | 51 ++++ src/lib/workbench/vault.test.ts | 36 +++ src/lib/workbench/vault.ts | 139 ++++++++++ src/pages/AnalyticsPage.tsx | 104 +++++++ src/pages/ConnectorsPage.test.tsx | 24 ++ src/pages/ConnectorsPage.tsx | 104 +++++++ src/pages/DocsPage.tsx | 151 ++++++++++ src/pages/HealthPage.tsx | 143 ++++++++++ src/pages/HomePage.tsx | 261 +++++++----------- src/pages/PilotPage.tsx | 118 ++++++++ src/pages/ReviewQueuePage.tsx | 122 ++++++++ src/pages/ScenariosPage.tsx | 127 +++++++++ src/pages/StorytellerPage.tsx | 14 + src/pages/VaultPage.test.tsx | 17 ++ src/pages/VaultPage.tsx | 181 ++++++++++++ src/pages/WorkbenchPage.test.tsx | 46 +++ src/pages/WorkbenchPage.tsx | 172 ++++++++++++ 84 files changed, 5227 insertions(+), 198 deletions(-) create mode 100644 apps/payment-intelligence-pilot/.gitignore create mode 100644 apps/payment-intelligence-pilot/Makefile create mode 100644 apps/payment-intelligence-pilot/README.md create mode 100644 apps/payment-intelligence-pilot/app/__init__.py create mode 100644 apps/payment-intelligence-pilot/app/api/__init__.py create mode 100644 apps/payment-intelligence-pilot/app/api/analyses.py create mode 100644 apps/payment-intelligence-pilot/app/api/approvals.py create mode 100644 apps/payment-intelligence-pilot/app/api/audit.py create mode 100644 apps/payment-intelligence-pilot/app/api/connectors.py create mode 100644 apps/payment-intelligence-pilot/app/api/dashboard.py create mode 100644 apps/payment-intelligence-pilot/app/api/health.py create mode 100644 apps/payment-intelligence-pilot/app/api/review.py create mode 100644 apps/payment-intelligence-pilot/app/config.py create mode 100644 apps/payment-intelligence-pilot/app/db.py create mode 100644 apps/payment-intelligence-pilot/app/deps.py create mode 100644 apps/payment-intelligence-pilot/app/errors.py create mode 100644 apps/payment-intelligence-pilot/app/main.py create mode 100644 apps/payment-intelligence-pilot/app/models.py create mode 100644 apps/payment-intelligence-pilot/app/schemas.py create mode 100644 apps/payment-intelligence-pilot/app/seed.py create mode 100644 apps/payment-intelligence-pilot/app/services/__init__.py create mode 100644 apps/payment-intelligence-pilot/app/services/approvals.py create mode 100644 apps/payment-intelligence-pilot/app/services/audit.py create mode 100644 apps/payment-intelligence-pilot/app/services/connectors/__init__.py create mode 100644 apps/payment-intelligence-pilot/app/services/connectors/profiles.py create mode 100644 apps/payment-intelligence-pilot/app/services/connectors/registry.py create mode 100644 apps/payment-intelligence-pilot/app/services/readiness.py create mode 100644 apps/payment-intelligence-pilot/app/services/review.py create mode 100644 apps/payment-intelligence-pilot/pyproject.toml create mode 100644 apps/payment-intelligence-pilot/tests/conftest.py create mode 100644 apps/payment-intelligence-pilot/tests/test_analyses_privacy.py create mode 100644 apps/payment-intelligence-pilot/tests/test_approvals_four_eyes.py create mode 100644 apps/payment-intelligence-pilot/tests/test_audit_immutability.py create mode 100644 apps/payment-intelligence-pilot/tests/test_auth_stub.py create mode 100644 apps/payment-intelligence-pilot/tests/test_connector_disabled_guard.py create mode 100644 apps/payment-intelligence-pilot/tests/test_connector_registry.py create mode 100644 apps/payment-intelligence-pilot/tests/test_health.py create mode 100644 apps/payment-intelligence-pilot/tests/test_operator_summary.py create mode 100644 e2e/workbench.spec.ts create mode 100644 src/App.workbench.test.tsx create mode 100644 src/components/workbench/CapabilityBadge.tsx create mode 100644 src/components/workbench/CaveatPanel.tsx create mode 100644 src/components/workbench/FindingsList.tsx create mode 100644 src/components/workbench/GateChecklist.tsx create mode 100644 src/components/workbench/MaturityLegend.tsx create mode 100644 src/components/workbench/ProvenanceCard.tsx create mode 100644 src/lib/workbench/analytics.test.ts create mode 100644 src/lib/workbench/analytics.ts create mode 100644 src/lib/workbench/connectors.test.ts create mode 100644 src/lib/workbench/connectors.ts create mode 100644 src/lib/workbench/index.ts create mode 100644 src/lib/workbench/review.test.ts create mode 100644 src/lib/workbench/review.ts create mode 100644 src/lib/workbench/scenarios.test.ts create mode 100644 src/lib/workbench/scenarios.ts create mode 100644 src/lib/workbench/session/SessionContext.ts create mode 100644 src/lib/workbench/session/SessionProvider.tsx create mode 100644 src/lib/workbench/session/index.ts create mode 100644 src/lib/workbench/session/useSession.ts create mode 100644 src/lib/workbench/surfaces.ts create mode 100644 src/lib/workbench/taxonomy.test.ts create mode 100644 src/lib/workbench/taxonomy.ts create mode 100644 src/lib/workbench/types.ts create mode 100644 src/lib/workbench/vault.test.ts create mode 100644 src/lib/workbench/vault.ts create mode 100644 src/pages/AnalyticsPage.tsx create mode 100644 src/pages/ConnectorsPage.test.tsx create mode 100644 src/pages/ConnectorsPage.tsx create mode 100644 src/pages/DocsPage.tsx create mode 100644 src/pages/HealthPage.tsx create mode 100644 src/pages/PilotPage.tsx create mode 100644 src/pages/ReviewQueuePage.tsx create mode 100644 src/pages/ScenariosPage.tsx create mode 100644 src/pages/VaultPage.test.tsx create mode 100644 src/pages/VaultPage.tsx create mode 100644 src/pages/WorkbenchPage.test.tsx create mode 100644 src/pages/WorkbenchPage.tsx diff --git a/.prettierignore b/.prettierignore index bf49bd3..8dacbd5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ playwright-report test-results pnpm-lock.yaml apps/ssi-control-tower +apps/payment-intelligence-pilot diff --git a/README.md b/README.md index 803fa5c..dcf5aee 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,15 @@ The root suite remains static and browser-only. Standalone adjacent applications ## Modules -| Module | Route | Status | What it does | -| --------------------------- | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Scrubber** | `/scrubber` | Available | Strip personally identifying fields from `pacs.*` / `camt.*` XML before sharing with peers or vendors. Produces a privacy-safe mapping summary. | -| **Storyteller** | `/storyteller` | Available | Turn a `pacs.002 / 004 / 008 / 009` or `camt.052 / 053 / 054` message into a plain-language narrative plus a structured field projection and quick insights. | -| **IBAN Workbench** | `/iban` | Available | Validate, build, catalogue, and trace provenance for IBANs from bundled SWIFT IBAN Registry-derived data. Builder computes MOD-97 check digits from exact-length BBAN fields; no live BIC, VOP, account existence, or reachability checks. | -| **BIC Validator\*** | `/bic` | Demo | ISO 9362 syntax checks plus a tiny bundled snapshot lookup. **Demonstration only:** bundled BIC data is not accurate/current enough for production, routing, compliance, reachability, or payment decisions. | -| **CBPR+ Readiness Checker** | `/cbpr` | Available | Browser-only AppHdr / Document namespace / CBPR+ schema-profile coverage checks, plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator or MyStandards usage-rule engine. | -| **Payment Insights Lite** | `/insights` | Available | Local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files you provide. Groups files by identifiers in memory; not live payment tracking, VOP, reachability, or settlement monitoring. | -| Vault | — | Planned | Planned encrypted local export bundle: user-controlled download/import, no cloud vault, no server storage, and no persistent browser storage by default. Not built yet. | +| Module | Route | Status | What it does | +| --------------------------- | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Scrubber** | `/scrubber` | Available | Strip personally identifying fields from `pacs.*` / `camt.*` XML before sharing with peers or vendors. Produces a privacy-safe mapping summary. | +| **Storyteller** | `/storyteller` | Available | Turn a `pacs.002 / 004 / 008 / 009` or `camt.052 / 053 / 054` message into a plain-language narrative plus a structured field projection and quick insights. | +| **IBAN Workbench** | `/iban` | Available | Validate, build, catalogue, and trace provenance for IBANs from bundled SWIFT IBAN Registry-derived data. Builder computes MOD-97 check digits from exact-length BBAN fields; no live BIC, VOP, account existence, or reachability checks. | +| **BIC Validator\*** | `/bic` | Demo | ISO 9362 syntax checks plus a tiny bundled snapshot lookup. **Demonstration only:** bundled BIC data is not accurate/current enough for production, routing, compliance, reachability, or payment decisions. | +| **CBPR+ Readiness Checker** | `/cbpr` | Available | Browser-only AppHdr / Document namespace / CBPR+ schema-profile coverage checks, plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator or MyStandards usage-rule engine. | +| **Payment Insights Lite** | `/insights` | Available | Local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files you provide. Groups files by identifiers in memory; not live payment tracking, VOP, reachability, or settlement monitoring. | +| Vault | `/vault` | Prototype | User-controlled encrypted local export/import prototype using browser crypto and file download/import only; no cloud vault, no server storage, and no persistent browser storage by default. Security threat model still required before sensitive use. | \*The BIC module intentionally does **not** perform live BIC Directory lookup, current bank-directory enrichment, current SEPA reachability checks, SWIFT FIN diff --git a/apps/payment-intelligence-pilot/.gitignore b/apps/payment-intelligence-pilot/.gitignore new file mode 100644 index 0000000..1b48faa --- /dev/null +++ b/apps/payment-intelligence-pilot/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.db +.pytest_cache/ +exports/ +.venv/ +*.egg-info/ +data/ diff --git a/apps/payment-intelligence-pilot/Makefile b/apps/payment-intelligence-pilot/Makefile new file mode 100644 index 0000000..137c41d --- /dev/null +++ b/apps/payment-intelligence-pilot/Makefile @@ -0,0 +1,21 @@ +PYTHON ?= python3.11 +VENV := .venv +PIP := $(VENV)/bin/pip +PY := $(VENV)/bin/python +UVICORN := $(VENV)/bin/uvicorn + +$(PY): pyproject.toml + $(PYTHON) -m venv $(VENV) + $(PIP) install --upgrade pip + $(PIP) install -e ".[test]" + +.PHONY: run test clean +run: $(PY) + $(UVICORN) app.main:app --host 0.0.0.0 --port 8100 --reload + +test: $(PY) + $(PY) -m pytest -q + +clean: + rm -rf $(VENV) .pytest_cache exports *.db __pycache__ + find . -name "__pycache__" -type d -prune -exec rm -rf {} + diff --git a/apps/payment-intelligence-pilot/README.md b/apps/payment-intelligence-pilot/README.md new file mode 100644 index 0000000..7bb5aa0 --- /dev/null +++ b/apps/payment-intelligence-pilot/README.md @@ -0,0 +1,137 @@ +# Payment Intelligence Pilot (DEMO / PROTOTYPE) + +> **READ THIS FIRST.** This app is an **honest, gated prototype**. It is **NOT +> production**, **NOT the public static root suite**, and it performs **NO real +> external provider calls**. Every connector is **disabled** +> (`live_integration = False`). Authentication is a **DEMO STUB — NOT PRODUCTION +> AUTH**. All data is **synthetic**. + +A small, self-contained FastAPI app scaffolding the **P2 (hosted private-pilot)** +and **P3 (enterprise / live-data readiness)** tiers of a payment-intelligence +workbench. It exists to *show the shape* of an operator workflow and a live-data +connector spine — with every dangerous capability deliberately gated off. + +## What this is (and is not) + +| | | +|---|---| +| **Is** | A local-only, offline, synthetic FastAPI prototype of an operator workbench (review queue, four-eyes approvals, immutable audit, readiness scoring) plus a **disabled** connector registry (P3). | +| **Is NOT** | Production. A live integration. The public static "root suite". A real authentication system. A source of real BIC/VOP/reachability/settlement/payment data. | + +### Separation from the root suite + +This app lives entirely under `apps/payment-intelligence-pilot/` and is a **fully +independent** application. It is **not** wired into, and shares no code or data +with, the public static root suite or `apps/ssi-control-tower/`. It only mirrors +the *patterns* of `ssi-control-tower` conceptually (app factory, immutable audit, +four-eyes, disabled provider registry); it does not import from it. + +## The gate model (hard gates) + +These are non-negotiable and enforced in code, tests, and API responses: + +1. **No real external calls.** No `app/` runtime code imports any HTTP/socket/ + SFTP/MQ client. (`httpx` is declared only as the transport FastAPI's + `TestClient` needs for the test suite — it is never imported by the app.) The + only connector "adapter" is `invoke_connector(...)`, a stub that **always + raises** `ConnectorDisabledError`. +2. **Every connector defaults `live_integration = False` and `enabled = False`.** + `register_connector()` *refuses* to register anything claiming to be live or + enabled. `gate_matrix()` asserts `all_disabled` and `live_integration_count == 0`. +3. **DEMO STUB auth.** `current_actor()` trusts the `X-User-Email` header verbatim + and maps it to a seeded demo role. No password, token, signature, or session. + It is labelled `DEMO STUB — NOT PRODUCTION AUTH` in code, README, and API + responses (`/`, `/health`, dashboard). +4. **Synthetic data only.** Fake BICs (e.g. `TESTGB2LXXX`), pre-masked accounts + (e.g. `****1111`), and explicit `_synthetic_notice` / `synthetic_notice` + fields. Creating an analysis with an **unmasked account-like** value is + rejected with HTTP 400 (`UNMASKED_ACCOUNT_FIELD`). +5. **Scrub-before-store.** Records hold only aggregates / metadata / masked refs. + No raw message payloads or PII are ever persisted. +6. **No deploy / push / real DB.** SQLite is a local, ephemeral file under the app + dir (or a tmp path via `PIP_DB_PATH`). No migrations against any real database. + +### How `live_integration` flags work + +Each connector profile (`app/services/connectors/profiles.py`) carries +`live_integration=False`, `enabled=False`, and a `ConnectorCapabilities` block +(also `live_integration=False`, `provides_live_data=False`). The registry will not +accept any other value. The health endpoint reports +`live_integrations_enabled: 0`, derived from the persisted `ConnectorState` rows — +it is **not** a hard-coded literal, it is the honest count, and that count is +always zero because nothing can be enabled. + +The seven connector **tracks** (all disabled) model the *class* of system a future +production build *could* integrate with: + +| Track | Illustrative class (no affiliation, no live data) | +|---|---| +| `BIC_DIRECTORY` | SwiftRef SSI Plus / BIC Directory | +| `VOP` | EBA/EPC Verification of Payee scheme | +| `REACHABILITY` | SEPA + FIN reachability | +| `MQ` | IBM MQ message feed | +| `DIRECTORY_FILE_DROP` | DTCC ALERT-style directory file drop | +| `PAYMENT_MONITOR` | payment / settlement monitor | +| `CERTIFIED_CBPR` | MyStandards / CBPR+ certified cross-border | + +## API surfaces + +- `GET /health`, `GET /api/v1/health` — honest service status, `environment: "prototype"`, `live_integrations_enabled: 0`, disclaimer. +- `GET /api/v1/dashboard/operator-summary` — aggregate counts only (review queue, pending approvals, readiness, connector gate summary). +- `GET /api/v1/dashboard/readiness` — weighted readiness score + band. +- `GET/POST /api/v1/analyses` — list / create aggregate analysis records (rejects unmasked account-like fields). +- `GET/POST /api/v1/review`, `POST /api/v1/review/{id}/assign|resolve` — review queue. +- `GET/POST /api/v1/approvals`, `POST /api/v1/approvals/{id}/approve|reject` — four-eyes (self-approval → 403). +- `GET /api/v1/audit` — immutable audit trail. +- `GET /api/v1/connectors`, `GET /api/v1/connectors/{id}`, `POST /api/v1/connectors/{id}/invoke` — P3 spine. **Invoke always returns 409** (`CONNECTOR_DISABLED`). + +## Run + +```bash +cd apps/payment-intelligence-pilot +make run # uvicorn app.main:app --port 8100 (creates .venv on first run) +# open http://127.0.0.1:8100/docs +``` + +## Test + +```bash +cd apps/payment-intelligence-pilot +make test # python -m pytest -q (creates .venv on first run) +# or, if deps already installed: +python -m pytest -q +``` + +Tests use a per-test temporary SQLite file (via `PIP_DB_PATH`) and never touch a +real database. + +## Configuration + +| Env var | Default | Purpose | +|---|---|---| +| `PIP_DB_PATH` | `data/payment_intelligence_pilot.db` | Local SQLite file (ephemeral). | +| `PIP_EXPORT_DIR` | `exports/` | Local export dir (unused in normal flows). | + +No secrets, credentials, or env secrets are read anywhere. + +## NOT implemented — requires Raf sign-off before any of this is real + +This prototype intentionally stops at the gate. The following are **out of scope** +and **must not** be inferred to exist: + +- **Real authentication / authorization** (OIDC/SAML/mTLS, sessions, RBAC of + record). The header trust is a stub only. +- **Any live connector** — BIC directory, VOP, reachability, MQ, directory file + drop, payment/settlement monitor, certified CBPR+. All require, per track: + - a **signed data/usage license** countersigned by the vendor, + - **compliance sign-off** (sanctions/PEP posture, scheme rulebook adherence), + - an independent **security review + penetration test**, + - scheme **certification / conformance** (e.g. MyStandards / CBPR+), + - a **Data Processing Agreement + data-residency review**. +- **Signed data licenses** for any reference directory. +- **Certification** against any payment scheme. +- **Penetration testing** sign-off. +- **DPA / data-residency** approval for processing any real party/payment data. + +Nothing here grants, implies, or stages any of the above. Enabling a live +integration is deliberately impossible from this code path. diff --git a/apps/payment-intelligence-pilot/app/__init__.py b/apps/payment-intelligence-pilot/app/__init__.py new file mode 100644 index 0000000..0a029f6 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/__init__.py @@ -0,0 +1,5 @@ +"""Payment Intelligence Pilot — honest, gated P2/P3 prototype. + +Local-only, synthetic, offline. No real external provider calls. All connectors +are disabled with ``live_integration = False``. Auth is a loud DEMO STUB. +""" diff --git a/apps/payment-intelligence-pilot/app/api/__init__.py b/apps/payment-intelligence-pilot/app/api/__init__.py new file mode 100644 index 0000000..2c515f2 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/api/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy.inspection import inspect + + +def as_dict(obj: Any) -> dict[str, Any]: + """Serialize a SQLAlchemy row to a plain dict of its mapped columns.""" + return {column.key: getattr(obj, column.key) for column in inspect(obj).mapper.column_attrs} diff --git a/apps/payment-intelligence-pilot/app/api/analyses.py b/apps/payment-intelligence-pilot/app/api/analyses.py new file mode 100644 index 0000000..5f94ae8 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/api/analyses.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import re + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.config import utc_now +from app.deps import current_actor, get_db +from app.errors import raise_app_error +from app.models import AnalysisRecord +from app.schemas import AnalysisCreate +from app.seed import SYNTHETIC_NOTICE +from app.services.audit import stable_id, write_audit + +router = APIRouter(prefix="/api/v1/analyses", tags=["analyses"]) + +# Account-like = a run of 8+ digits (optionally IBAN-ish). Reject if unmasked. +_ACCOUNT_LIKE = re.compile(r"\b\d{8,}\b") +_IBAN_LIKE = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{10,}\b") + + +def _reject_unmasked(field_name: str, value: str | None) -> None: + if not value: + return + text = str(value) + if "****" in text: + return # masked references are allowed + if _ACCOUNT_LIKE.search(text) or _IBAN_LIKE.search(text): + raise_app_error( + 400, + f"{field_name} appears to contain an unmasked account-like value; mask it (e.g. ****1111) before storing", + "UNMASKED_ACCOUNT_FIELD", + ) + + +@router.get("") +def list_analyses(session: Session = Depends(get_db)): + """Aggregate/metadata analysis records only. No raw payloads are ever stored.""" + records = session.query(AnalysisRecord).order_by(AnalysisRecord.created_at.desc()).all() + return {"count": len(records), "synthetic_notice": SYNTHETIC_NOTICE, "items": [as_dict(r) for r in records]} + + +@router.post("", status_code=201) +def create_analysis(body: AnalysisCreate, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + """Create an aggregate analysis record. Unmasked account-like fields → 400.""" + # Scrub-before-store guard: no field may carry an unmasked account number. + _reject_unmasked("summary", body.summary) + _reject_unmasked("masked_reference", body.masked_reference) + now = utc_now() + record = AnalysisRecord( + id=stable_id("analysis", body.module, body.message_family, body.summary, now), + module=body.module, + message_family=body.message_family, + result_status=body.result_status, + finding_count=body.finding_count, + summary=body.summary, + masked_reference=body.masked_reference, + synthetic_notice=SYNTHETIC_NOTICE, + created_at=now, + ) + session.add(record) + write_audit( + session, + entity_type="analysis", + entity_id=record.id, + action="analysis.created", + actor_user_email=actor, + new_value={"module": body.module, "result_status": body.result_status, "finding_count": body.finding_count}, + ) + session.commit() + return as_dict(record) diff --git a/apps/payment-intelligence-pilot/app/api/approvals.py b/apps/payment-intelligence-pilot/app/api/approvals.py new file mode 100644 index 0000000..1fad5e8 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/api/approvals.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import current_actor, get_db +from app.models import ApprovalRequest +from app.schemas import ApprovalDecision, ApprovalSubmit +from app.services import approvals as approvals_service + +router = APIRouter(prefix="/api/v1/approvals", tags=["approvals"]) + + +@router.get("") +def list_approvals(session: Session = Depends(get_db)): + items = session.query(ApprovalRequest).order_by(ApprovalRequest.created_at.desc()).all() + return {"count": len(items), "items": [as_dict(i) for i in items]} + + +@router.post("", status_code=201) +def submit_approval(body: ApprovalSubmit, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + approval = approvals_service.submit_for_approval( + session, + subject=body.subject, + change_summary=body.change_summary, + actor=actor, + review_item_id=body.review_item_id, + risk_level=body.risk_level, + ) + return as_dict(approval) + + +@router.post("/{approval_id}/approve") +def approve_approval(approval_id: str, body: ApprovalDecision, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + """Four-eyes approve. Self-approval (maker == checker) returns 403.""" + approval = approvals_service.approve(session, approval_id, actor, body.reason) + return as_dict(approval) + + +@router.post("/{approval_id}/reject") +def reject_approval(approval_id: str, body: ApprovalDecision, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + approval = approvals_service.reject(session, approval_id, actor, body.reason) + return as_dict(approval) diff --git a/apps/payment-intelligence-pilot/app/api/audit.py b/apps/payment-intelligence-pilot/app/api/audit.py new file mode 100644 index 0000000..d83b03f --- /dev/null +++ b/apps/payment-intelligence-pilot/app/api/audit.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import get_db +from app.models import AuditEvent + +router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) + + +@router.get("") +def list_audit(session: Session = Depends(get_db)): + """List the immutable (insert-only) audit trail. UPDATE/DELETE blocked by DB triggers.""" + events = session.query(AuditEvent).order_by(AuditEvent.created_at.asc()).all() + return {"count": len(events), "immutable": True, "items": [as_dict(e) for e in events]} diff --git a/apps/payment-intelligence-pilot/app/api/connectors.py b/apps/payment-intelligence-pilot/app/api/connectors.py new file mode 100644 index 0000000..07341b3 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/api/connectors.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from app.errors import raise_app_error +from app.services.connectors.registry import ( + ConnectorDisabledError, + gate_matrix, + get_connector, + invoke_connector, +) + +router = APIRouter(prefix="/api/v1/connectors", tags=["connectors"]) + + +@router.get("") +def list_connectors_route(): + """Gate matrix + per-connector public_dict. Every connector is DISABLED.""" + return gate_matrix() + + +@router.get("/{connector_id}") +def get_connector_route(connector_id: str): + profile = get_connector(connector_id) + if profile is None: + raise_app_error(404, "Connector not found", "CONNECTOR_NOT_FOUND") + return profile.public_dict() + + +@router.post("/{connector_id}/invoke") +def invoke_connector_route(connector_id: str): + """Proves disabled connectors cannot be called. ALWAYS errors (never 200). + + Attempts to invoke the connector via the stub adapter, which raises + :class:`ConnectorDisabledError`. We surface that as HTTP 409 (Conflict) + together with the gate requirements, so a caller can see *why* it is gated. + """ + profile = get_connector(connector_id) + if profile is None: + raise_app_error(404, "Connector not found", "CONNECTOR_NOT_FOUND") + try: + invoke_connector(connector_id) + except ConnectorDisabledError as exc: + raise_app_error( + 409, + ( + f"Connector '{connector_id}' is disabled and cannot be invoked: {exc.reason}. " + f"Gate requirements: {', '.join(profile.gate_requirements)}." + ), + "CONNECTOR_DISABLED", + ) + # Defensive: there is no success path in this prototype. + raise_app_error(500, "Unexpected: connector invocation did not raise", "CONNECTOR_INVARIANT_VIOLATION") diff --git a/apps/payment-intelligence-pilot/app/api/dashboard.py b/apps/payment-intelligence-pilot/app/api/dashboard.py new file mode 100644 index 0000000..c1f8f15 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/api/dashboard.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.config import DISCLAIMER, ENVIRONMENT +from app.deps import AUTH_STUB_NOTICE, get_db +from app.models import ApprovalRequest, ReviewItem +from app.services.connectors.registry import gate_matrix +from app.services.readiness import pilot_readiness + +router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"]) + + +def _review_breakdown(session: Session) -> dict: + items = session.query(ReviewItem).all() + by_severity: dict[str, int] = {} + by_status: dict[str, int] = {} + for i in items: + by_severity[i.severity] = by_severity.get(i.severity, 0) + 1 + by_status[i.status] = by_status.get(i.status, 0) + 1 + return {"total": len(items), "by_severity": by_severity, "by_status": by_status} + + +@router.get("/operator-summary") +def operator_summary(session: Session = Depends(get_db)): + """Aggregate counts ONLY. No raw payloads, no PII, no unmasked accounts.""" + gm = gate_matrix() + pending_approvals = session.query(ApprovalRequest).filter(ApprovalRequest.status == "pending").count() + return { + "environment": ENVIRONMENT, + "auth": AUTH_STUB_NOTICE, + "disclaimer": DISCLAIMER, + "review_queue": _review_breakdown(session), + "approvals": {"pending": pending_approvals}, + "readiness": pilot_readiness(session), + "connector_gates": { + "total_connectors": gm["total_connectors"], + "enabled_count": gm["enabled_count"], + "live_integration_count": gm["live_integration_count"], + "all_disabled": gm["all_disabled"], + }, + } + + +@router.get("/readiness") +def readiness(session: Session = Depends(get_db)): + return pilot_readiness(session) diff --git a/apps/payment-intelligence-pilot/app/api/health.py b/apps/payment-intelligence-pilot/app/api/health.py new file mode 100644 index 0000000..7a811c8 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/api/health.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.config import DISCLAIMER, ENVIRONMENT +from app.deps import AUTH_STUB_NOTICE, get_db +from app.models import ConnectorState + +router = APIRouter(tags=["health"]) + + +def _health_payload(session: Session) -> dict: + try: + session.execute(text("SELECT 1")) + db_ok = True + except Exception: + db_ok = False + # Count is honest and derived from data: there are no live integrations. + live = session.query(ConnectorState).filter( + ConnectorState.enabled.is_(True), ConnectorState.live_integration.is_(True) + ).count() + return { + "status": "ok" if db_ok else "degraded", + "service": "payment-intelligence-pilot", + "version": "0.1.0", + "environment": ENVIRONMENT, + "db_ok": db_ok, + "live_integrations_enabled": live, # always 0 in this prototype + "auth": AUTH_STUB_NOTICE, + "disclaimer": DISCLAIMER, + } + + +@router.get("/health") +def health(session: Session = Depends(get_db)): + return _health_payload(session) + + +@router.get("/api/v1/health") +def health_v1(session: Session = Depends(get_db)): + return _health_payload(session) diff --git a/apps/payment-intelligence-pilot/app/api/review.py b/apps/payment-intelligence-pilot/app/api/review.py new file mode 100644 index 0000000..b80731f --- /dev/null +++ b/apps/payment-intelligence-pilot/app/api/review.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api import as_dict +from app.deps import current_actor, get_db +from app.models import ReviewItem +from app.schemas import ReviewAssign, ReviewCreate, ReviewResolve +from app.services import review as review_service + +router = APIRouter(prefix="/api/v1/review", tags=["review"]) + + +@router.get("") +def list_review(session: Session = Depends(get_db)): + items = session.query(ReviewItem).order_by(ReviewItem.created_at.desc()).all() + return {"count": len(items), "items": [as_dict(i) for i in items]} + + +@router.post("", status_code=201) +def create_review(body: ReviewCreate, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + item = review_service.create_review_item( + session, + analysis_id=body.analysis_id, + severity=body.severity, + title=body.title, + actor=actor, + sla_deadline=body.sla_deadline, + ) + return as_dict(item) + + +@router.post("/{item_id}/assign") +def assign_review(item_id: str, body: ReviewAssign, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + item = review_service.assign_review_item(session, item_id, body.owner_email, actor) + return as_dict(item) + + +@router.post("/{item_id}/resolve") +def resolve_review(item_id: str, body: ReviewResolve, session: Session = Depends(get_db), actor: str = Depends(current_actor)): + item = review_service.resolve_review_item(session, item_id, actor, body.reason) + return as_dict(item) diff --git a/apps/payment-intelligence-pilot/app/config.py b/apps/payment-intelligence-pilot/app/config.py new file mode 100644 index 0000000..c965a17 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/config.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = ROOT_DIR / "data" + +# Honest, fixed environment label. This prototype is NEVER "production". +ENVIRONMENT = "prototype" +DISCLAIMER = ( + "PROTOTYPE / DEMO ONLY. Synthetic offline data. No real external provider " + "calls. All connectors are disabled (live_integration=False). Auth is a " + "DEMO STUB — NOT PRODUCTION AUTH." +) + + +def database_path() -> Path: + """Local/ephemeral SQLite path. Env-overridable; defaults under the app dir.""" + return Path(os.getenv("PIP_DB_PATH", str(DATA_DIR / "payment_intelligence_pilot.db"))) + + +def export_dir() -> Path: + """Local export directory (never written to during normal pilot flows).""" + return Path(os.getenv("PIP_EXPORT_DIR", str(ROOT_DIR / "exports"))) + + +def utc_now() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/apps/payment-intelligence-pilot/app/db.py b/apps/payment-intelligence-pilot/app/db.py new file mode 100644 index 0000000..648e2d3 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/db.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import database_path + + +class Base(DeclarativeBase): + pass + + +_ENGINE = None +SessionLocal = sessionmaker(autoflush=False, autocommit=False, expire_on_commit=False) + + +def get_engine(): + """Return the process-local SQLite engine for the configured (local) database path.""" + global _ENGINE + if _ENGINE is None: + path = database_path() + path.parent.mkdir(parents=True, exist_ok=True) + _ENGINE = create_engine(f"sqlite:///{path}", connect_args={"check_same_thread": False}) + SessionLocal.configure(bind=_ENGINE) + return _ENGINE + + +def reset_engine() -> None: + """Dispose the cached engine so tests can swap database paths deterministically.""" + global _ENGINE + if _ENGINE is not None: + _ENGINE.dispose() + _ENGINE = None + SessionLocal.configure(bind=None) + + +def create_audit_triggers() -> None: + """Enforce audit immutability at the database layer (insert-only).""" + engine = get_engine() + with engine.begin() as conn: + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS audit_events_no_update + BEFORE UPDATE ON audit_events + BEGIN + SELECT RAISE(ABORT, 'audit_events are insert-only'); + END; + """)) + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS audit_events_no_delete + BEFORE DELETE ON audit_events + BEGIN + SELECT RAISE(ABORT, 'audit_events are insert-only'); + END; + """)) + + +def init_db() -> None: + """Create tables and audit immutability triggers.""" + from app import models # noqa: F401 + + Base.metadata.create_all(get_engine()) + create_audit_triggers() + + +def get_session() -> Session: + return SessionLocal() diff --git a/apps/payment-intelligence-pilot/app/deps.py b/apps/payment-intelligence-pilot/app/deps.py new file mode 100644 index 0000000..7ef9e7b --- /dev/null +++ b/apps/payment-intelligence-pilot/app/deps.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from collections.abc import Generator + +from fastapi import Header +from sqlalchemy.orm import Session + +from app.db import SessionLocal +from app.models import User + +# Loud, deliberate label. This is NOT a real authentication mechanism. +AUTH_STUB_NOTICE = "DEMO STUB — NOT PRODUCTION AUTH" +DEFAULT_DEMO_EMAIL = "analyst@example.com" +VALID_ROLES = ("analyst", "ops-owner", "approver", "risk", "admin") + + +def get_db() -> Generator[Session, None, None]: + session = SessionLocal() + try: + yield session + finally: + session.close() + + +def current_actor(x_user_email: str = Header(default=DEFAULT_DEMO_EMAIL, alias="X-User-Email")) -> str: + """DEMO STUB — NOT PRODUCTION AUTH. + + Trusts the ``X-User-Email`` request header verbatim and maps it to a seeded + demo user/role. There is no password, token, signature, or session check. + This exists ONLY so the prototype can exercise role-gated flows offline. + Replacing this with real authentication requires Raf sign-off (see README). + """ + return x_user_email + + +def actor_role(session: Session, email: str) -> str: + """Resolve the demo role for an actor email; unknown actors are ``system``.""" + user = session.get(User, email) + return user.role if user else "system" diff --git a/apps/payment-intelligence-pilot/app/errors.py b/apps/payment-intelligence-pilot/app/errors.py new file mode 100644 index 0000000..f8c21a8 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/errors.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from fastapi import Request +from fastapi.responses import JSONResponse + + +class AppError(Exception): + """Domain error carrying an HTTP status, a human detail, and a stable code.""" + + def __init__(self, status_code: int, detail: str, code: str) -> None: + self.status_code = status_code + self.detail = detail + self.code = code + + +async def app_error_handler(_: Request, exc: AppError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail, "code": exc.code}) + + +def raise_app_error(status_code: int, detail: str, code: str) -> None: + raise AppError(status_code, detail, code) diff --git a/apps/payment-intelligence-pilot/app/main.py b/apps/payment-intelligence-pilot/app/main.py new file mode 100644 index 0000000..dfa69f9 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/main.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.api import analyses, approvals, audit, connectors, dashboard, health, review +from app.config import DISCLAIMER +from app.db import get_session, init_db +from app.errors import AppError, app_error_handler +from app.seed import seed_database + +APP_TITLE = "Payment Intelligence Pilot (DEMO/PROTOTYPE)" +APP_DESCRIPTION = ( + "Honest, gated P2/P3 prototype for a payment-intelligence workbench. " + "Local-only, synthetic, offline. No real external provider calls. All connectors " + "disabled (live_integration=False). Auth is a DEMO STUB — NOT PRODUCTION AUTH. " + "This is NOT the public static root suite and is NOT production." +) + + +@asynccontextmanager +async def app_lifespan(_app: FastAPI) -> AsyncIterator[None]: + init_db() + with get_session() as session: + seed_database(session) + yield + + +def create_app() -> FastAPI: + app = FastAPI( + title=APP_TITLE, + version="0.1.0", + description=APP_DESCRIPTION, + lifespan=app_lifespan, + ) + app.add_exception_handler(AppError, app_error_handler) + app.include_router(health.router) + app.include_router(analyses.router) + app.include_router(review.router) + app.include_router(approvals.router) + app.include_router(audit.router) + app.include_router(dashboard.router) + app.include_router(connectors.router) + + @app.get("/") + def root() -> dict: + return { + "service": "payment-intelligence-pilot", + "environment": "prototype", + "auth": "DEMO STUB — NOT PRODUCTION AUTH", + "disclaimer": DISCLAIMER, + "docs": "/docs", + } + + return app + + +app = create_app() diff --git a/apps/payment-intelligence-pilot/app/models.py b/apps/payment-intelligence-pilot/app/models.py new file mode 100644 index 0000000..ae03e7c --- /dev/null +++ b/apps/payment-intelligence-pilot/app/models.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + +# NOTE ON DATA POSTURE +# -------------------- +# Every model here is aggregate / metadata-only. We NEVER store raw message +# payloads, account numbers, or PII. Account-like references are stored only in +# pre-masked form (e.g. ``****1111``). This is the "scrub-before-store" posture. + + +class User(Base): + """Seeded DEMO STUB users. Not a real identity store.""" + + __tablename__ = "users" + email: Mapped[str] = mapped_column(String, primary_key=True) + role: Mapped[str] = mapped_column(String, nullable=False) + display_name: Mapped[str] = mapped_column(String, nullable=False) + active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class AnalysisRecord(Base): + """Aggregate/metadata result of a payment-intelligence analysis run. + + Holds ONLY counts, statuses, masked references, and a short summary. No raw + payloads, no PII, no unmasked accounts. + """ + + __tablename__ = "analysis_records" + id: Mapped[str] = mapped_column(String, primary_key=True) + module: Mapped[str] = mapped_column(String, nullable=False) + message_family: Mapped[str] = mapped_column(String, nullable=False) # e.g. pacs.008, camt.053 (family only) + result_status: Mapped[str] = mapped_column(String, nullable=False) # clean | findings | error + finding_count: Mapped[int] = mapped_column(Integer, default=0) + summary: Mapped[str] = mapped_column(Text, nullable=False) # short human text, no payloads + masked_reference: Mapped[str | None] = mapped_column(String, nullable=True) # e.g. ****1111 + synthetic_notice: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class ReviewItem(Base): + """A triage/review queue entry derived from an analysis record.""" + + __tablename__ = "review_items" + id: Mapped[str] = mapped_column(String, primary_key=True) + analysis_id: Mapped[str] = mapped_column(String, ForeignKey("analysis_records.id"), nullable=False) + severity: Mapped[str] = mapped_column(String, nullable=False) # low | medium | high | critical + status: Mapped[str] = mapped_column(String, nullable=False, default="open") # open | assigned | resolved + owner_email: Mapped[str | None] = mapped_column(String, nullable=True) + sla_deadline: Mapped[str | None] = mapped_column(String, nullable=True) + title: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class ApprovalRequest(Base): + """Four-eyes approval request. Self-approval is blocked in the service layer.""" + + __tablename__ = "approval_requests" + id: Mapped[str] = mapped_column(String, primary_key=True) + review_item_id: Mapped[str | None] = mapped_column(String, nullable=True) + subject: Mapped[str] = mapped_column(String, nullable=False) + change_summary: Mapped[str] = mapped_column(Text, nullable=False) + risk_level: Mapped[str] = mapped_column(String, nullable=False, default="high") + status: Mapped[str] = mapped_column(String, nullable=False, default="pending") # pending | approved | rejected + requested_by: Mapped[str] = mapped_column(String, nullable=False) + decided_by: Mapped[str | None] = mapped_column(String, nullable=True) + decided_at: Mapped[str | None] = mapped_column(String, nullable=True) + decision_reason: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class AuditEvent(Base): + """Insert-only audit trail. UPDATE/DELETE blocked by DB triggers.""" + + __tablename__ = "audit_events" + audit_event_id: Mapped[str] = mapped_column(String, primary_key=True) + entity_type: Mapped[str] = mapped_column(String, nullable=False) + entity_id: Mapped[str] = mapped_column(String, nullable=False) + action: Mapped[str] = mapped_column(String, nullable=False) + actor_user_email: Mapped[str] = mapped_column(String, nullable=False) + actor_role: Mapped[str] = mapped_column(String, nullable=False) + previous_value: Mapped[str | None] = mapped_column(Text, nullable=True) + new_value: Mapped[str | None] = mapped_column(Text, nullable=True) + reason_code: Mapped[str | None] = mapped_column(String, nullable=True) + correlation_id: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[str] = mapped_column(String, nullable=False) + + +class ConnectorState(Base): + """Persisted gate state for a P3 connector. Defaults: disabled + not live. + + The authoritative connector definitions live in the in-memory registry + (``app.services.connectors``). This table records the *operational* gate + state and is seeded entirely from disabled profiles. + """ + + __tablename__ = "connector_states" + connector_id: Mapped[str] = mapped_column(String, primary_key=True) + display_name: Mapped[str] = mapped_column(String, nullable=False) + track: Mapped[str] = mapped_column(String, nullable=False) + live_integration: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + last_checked: Mapped[str | None] = mapped_column(String, nullable=True) + gate_note: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/apps/payment-intelligence-pilot/app/schemas.py b/apps/payment-intelligence-pilot/app/schemas.py new file mode 100644 index 0000000..d1a91fb --- /dev/null +++ b/apps/payment-intelligence-pilot/app/schemas.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class AnalysisCreate(BaseModel): + """Create payload for an aggregate analysis record. + + Aggregate/metadata only. Any account-like reference MUST already be masked + (contain ``****``); unmasked account-like fields are rejected with 400. + """ + + module: str + message_family: str + result_status: str = Field(pattern="^(clean|findings|error)$") + finding_count: int = Field(ge=0, default=0) + summary: str + masked_reference: str | None = None + + +class AnalysisOut(BaseModel): + id: str + module: str + message_family: str + result_status: str + finding_count: int + summary: str + masked_reference: str | None + synthetic_notice: str + created_at: str + + +class ReviewCreate(BaseModel): + analysis_id: str + severity: str = Field(pattern="^(low|medium|high|critical)$") + title: str + sla_deadline: str | None = None + + +class ReviewAssign(BaseModel): + owner_email: str + + +class ReviewResolve(BaseModel): + reason: str = "Resolved" + + +class ApprovalSubmit(BaseModel): + subject: str + change_summary: str + review_item_id: str | None = None + risk_level: str = Field(pattern="^(low|medium|high|critical)$", default="high") + + +class ApprovalDecision(BaseModel): + reason: str = "Decided" diff --git a/apps/payment-intelligence-pilot/app/seed.py b/apps/payment-intelligence-pilot/app/seed.py new file mode 100644 index 0000000..dbc0c20 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/seed.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.models import AnalysisRecord, ConnectorState, User +from app.services.audit import stable_id +from app.services.connectors.registry import list_connectors + +SYNTHETIC_NOTICE = "SYNTHETIC — fabricated demo data, not real payments/accounts/parties." + +# DEMO STUB users — all example.com. No real identities. +DEMO_USERS = [ + {"email": "analyst@example.com", "role": "analyst", "display_name": "Demo Analyst"}, + {"email": "ops-owner@example.com", "role": "ops-owner", "display_name": "Demo Ops Owner"}, + {"email": "approver@example.com", "role": "approver", "display_name": "Demo Approver"}, + {"email": "risk@example.com", "role": "risk", "display_name": "Demo Risk Officer"}, + {"email": "admin@example.com", "role": "admin", "display_name": "Demo Admin"}, +] + +# Synthetic analysis records. Fake BICs (TESTGB2LXXX) + pre-masked accounts (****1111). +DEMO_ANALYSES = [ + { + "module": "iso20022-structure", + "message_family": "pacs.008", + "result_status": "findings", + "finding_count": 3, + "summary": "Synthetic pacs.008 batch: 3 structural findings against TESTGB2LXXX routing.", + "masked_reference": "****1111", + }, + { + "module": "reconciliation", + "message_family": "camt.053", + "result_status": "clean", + "finding_count": 0, + "summary": "Synthetic camt.053 statement reconciled cleanly (TESTDEFFXXX).", + "masked_reference": "****2222", + }, + { + "module": "sanctions-screening-shape", + "message_family": "pacs.009", + "result_status": "findings", + "finding_count": 1, + "summary": "Synthetic pacs.009: 1 name-shape finding for review (no real screening performed).", + "masked_reference": "****3333", + }, +] + + +def seed_database(session: Session) -> None: + """Seed demo users, synthetic analyses, and disabled connector states once.""" + if session.query(User).count() == 0: + now = utc_now() + for item in DEMO_USERS: + session.add(User(email=item["email"], role=item["role"], display_name=item["display_name"], active=True, created_at=now)) + for item in DEMO_ANALYSES: + session.add(AnalysisRecord( + id=stable_id("analysis", item["module"], item["message_family"], item["summary"]), + module=item["module"], + message_family=item["message_family"], + result_status=item["result_status"], + finding_count=item["finding_count"], + summary=item["summary"], + masked_reference=item["masked_reference"], + synthetic_notice=SYNTHETIC_NOTICE, + created_at=now, + )) + + # Seed connector gate states from the (all-disabled) registry profiles. + if session.query(ConnectorState).count() == 0: + for profile in list_connectors(): + session.add(ConnectorState( + connector_id=profile.connector_id, + display_name=profile.display_name, + track=profile.track.value, + live_integration=False, # always False — hard gate + enabled=False, # always False — hard gate + last_checked=None, + gate_note="; ".join(profile.gate_requirements), + )) + session.commit() diff --git a/apps/payment-intelligence-pilot/app/services/__init__.py b/apps/payment-intelligence-pilot/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payment-intelligence-pilot/app/services/approvals.py b/apps/payment-intelligence-pilot/app/services/approvals.py new file mode 100644 index 0000000..409a177 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/services/approvals.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.errors import raise_app_error +from app.models import ApprovalRequest +from app.services.audit import stable_id, write_audit + + +def submit_for_approval( + session: Session, + *, + subject: str, + change_summary: str, + actor: str, + review_item_id: str | None = None, + risk_level: str = "high", +) -> ApprovalRequest: + """Open a four-eyes approval request. The maker may not later approve it.""" + now = utc_now() + approval = ApprovalRequest( + id=stable_id("approval", subject, actor, change_summary, now), + review_item_id=review_item_id, + subject=subject, + change_summary=change_summary, + risk_level=risk_level, + status="pending", + requested_by=actor, + decided_by=None, + decided_at=None, + decision_reason=None, + created_at=now, + ) + session.add(approval) + write_audit( + session, + entity_type="approval", + entity_id=approval.id, + action="approval.submitted", + actor_user_email=actor, + new_value={"subject": subject, "risk_level": risk_level}, + ) + session.commit() + return approval + + +def approve(session: Session, approval_id: str, actor: str, reason: str = "Approved") -> ApprovalRequest: + """Approve a pending request. Self-approval (maker == checker) is blocked (403).""" + approval = session.get(ApprovalRequest, approval_id) + if not approval: + raise_app_error(404, "Approval not found", "APPROVAL_NOT_FOUND") + if approval.requested_by == actor: + raise_app_error(403, "Self-approval is not allowed (four-eyes)", "SELF_APPROVAL_REJECTED") + if approval.status != "pending": + raise_app_error(409, "Approval is no longer pending", "APPROVAL_NOT_PENDING") + now = utc_now() + approval.status = "approved" + approval.decided_by = actor + approval.decided_at = now + approval.decision_reason = reason + write_audit( + session, + entity_type="approval", + entity_id=approval_id, + action="approval.approved", + actor_user_email=actor, + new_value={"reason": reason}, + ) + session.commit() + return approval + + +def reject(session: Session, approval_id: str, actor: str, reason: str = "Rejected") -> ApprovalRequest: + """Reject a pending request. Self-rejection by the maker is also blocked.""" + approval = session.get(ApprovalRequest, approval_id) + if not approval: + raise_app_error(404, "Approval not found", "APPROVAL_NOT_FOUND") + if approval.requested_by == actor: + raise_app_error(403, "The requester cannot decide their own request (four-eyes)", "SELF_DECISION_REJECTED") + if approval.status != "pending": + raise_app_error(409, "Approval is no longer pending", "APPROVAL_NOT_PENDING") + now = utc_now() + approval.status = "rejected" + approval.decided_by = actor + approval.decided_at = now + approval.decision_reason = reason + write_audit( + session, + entity_type="approval", + entity_id=approval_id, + action="approval.rejected", + actor_user_email=actor, + new_value={"reason": reason}, + ) + session.commit() + return approval diff --git a/apps/payment-intelligence-pilot/app/services/audit.py b/apps/payment-intelligence-pilot/app/services/audit.py new file mode 100644 index 0000000..1d31f2a --- /dev/null +++ b/apps/payment-intelligence-pilot/app/services/audit.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +import uuid +from itertools import count +from typing import Any + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.deps import actor_role +from app.models import AuditEvent + +NAMESPACE = uuid.UUID("8f1d2c3b-4a5e-46f7-8901-2c3d4e5f6a7b") +_AUDIT_COUNTER = count() + + +def stable_id(*parts: object) -> str: + return str(uuid.uuid5(NAMESPACE, ":".join(str(p) for p in parts))) + + +def write_audit( + session: Session, + *, + entity_type: str, + entity_id: str, + action: str, + actor_user_email: str, + previous_value: Any | None = None, + new_value: Any | None = None, + reason_code: str | None = None, + correlation_id: str | None = None, +) -> AuditEvent: + """Append an immutable audit event. Callers never update existing rows.""" + now = utc_now() + seq = next(_AUDIT_COUNTER) + corr = correlation_id or stable_id("corr", entity_type, entity_id, action, now, seq) + event = AuditEvent( + audit_event_id=stable_id("audit", entity_type, entity_id, action, now, seq), + entity_type=entity_type, + entity_id=entity_id, + action=action, + actor_user_email=actor_user_email, + actor_role=actor_role(session, actor_user_email), + previous_value=json.dumps(previous_value, sort_keys=True) if previous_value is not None else None, + new_value=json.dumps(new_value, sort_keys=True) if new_value is not None else None, + reason_code=reason_code, + correlation_id=corr, + created_at=now, + ) + session.add(event) + return event diff --git a/apps/payment-intelligence-pilot/app/services/connectors/__init__.py b/apps/payment-intelligence-pilot/app/services/connectors/__init__.py new file mode 100644 index 0000000..a1e552c --- /dev/null +++ b/apps/payment-intelligence-pilot/app/services/connectors/__init__.py @@ -0,0 +1,18 @@ +"""P3 connector/provider spine. + +Importing this package registers the built-in (all-disabled) connector profiles +so the registry is populated for both the API and the tests. +""" + +from app.services.connectors import profiles # noqa: F401 (registers built-ins on import) +from app.services.connectors.registry import ( # noqa: F401 + ConnectorCapabilities, + ConnectorDisabledError, + ConnectorProfile, + ConnectorTrack, + gate_matrix, + get_connector, + invoke_connector, + list_connectors, + register_connector, +) diff --git a/apps/payment-intelligence-pilot/app/services/connectors/profiles.py b/apps/payment-intelligence-pilot/app/services/connectors/profiles.py new file mode 100644 index 0000000..1d3b759 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/services/connectors/profiles.py @@ -0,0 +1,164 @@ +"""Built-in connector profiles — one per track, all DISABLED. + +Importing this module registers exactly one disabled connector per +:class:`ConnectorTrack`. Every profile has ``live_integration=False`` and +``enabled=False`` and honest, track-appropriate gate requirements. + +None of these perform any network call. ``vendor_examples`` are illustrative of +the *class* of system only; there is no affiliation with, or live data from, any +named scheme/vendor. +""" + +from __future__ import annotations + +from app.services.connectors.registry import ( + ConnectorCapabilities, + ConnectorProfile, + ConnectorTrack, + register_connector, +) + +LICENSE_GATE = "Signed data/usage license countersigned by vendor + Raf sign-off" +COMPLIANCE_GATE = "Compliance sign-off (sanctions/PEP posture, scheme rulebook adherence)" +SECURITY_GATE = "Security review + independent penetration test passed" +CERT_GATE = "Scheme certification / conformance test passed (e.g. MyStandards / CBPR+)" +DPA_GATE = "Data Processing Agreement + data-residency review completed" + + +def register_builtin_connectors() -> None: + register_connector( + ConnectorProfile( + connector_id="bic_directory", + display_name="BIC / IBAN directory lookup (DISABLED)", + track=ConnectorTrack.BIC_DIRECTORY, + vendor_examples=("SwiftRef SSI Plus / BIC Directory",), + capabilities=ConnectorCapabilities( + requires_license=True, + requires_certification=False, + requires_pentest=True, + requires_dpa=True, + provides_live_data=False, + ), + licensing_note="Modelled BIC/IBAN directory. No live SwiftRef/BIC lookups; not affiliated with Swift.", + compliance_note="Directory data is licensed reference data; redistribution is restricted.", + security_note="Disabled. No outbound calls. Would require pen-test before any enablement.", + gate_requirements=(LICENSE_GATE, SECURITY_GATE, DPA_GATE), + ) + ) + register_connector( + ConnectorProfile( + connector_id="vop", + display_name="Verification of Payee (VOP) (DISABLED)", + track=ConnectorTrack.VOP, + vendor_examples=("EBA/EPC VOP scheme",), + capabilities=ConnectorCapabilities( + requires_license=True, + requires_certification=True, + requires_pentest=True, + requires_dpa=True, + provides_live_data=False, + ), + licensing_note="Models the EPC VOP scheme shape only. No live VOP requests of any kind.", + compliance_note="VOP participation requires scheme adherence and routing/SPAA agreements.", + security_note="Disabled. Name-matching of real payees would be PII-bearing; gated off entirely.", + gate_requirements=(LICENSE_GATE, COMPLIANCE_GATE, CERT_GATE, SECURITY_GATE, DPA_GATE), + ) + ) + register_connector( + ConnectorProfile( + connector_id="reachability", + display_name="SEPA/FIN reachability lookup (DISABLED)", + track=ConnectorTrack.REACHABILITY, + vendor_examples=("SEPA+FIN reachability",), + capabilities=ConnectorCapabilities( + requires_license=True, + requires_certification=False, + requires_pentest=True, + requires_dpa=False, + provides_live_data=False, + ), + licensing_note="Models scheme reachability tables only. No live reachability/RMA queries.", + compliance_note="Reachability data is scheme-licensed; usage bounded by scheme terms.", + security_note="Disabled. No outbound calls to any directory or scheme service.", + gate_requirements=(LICENSE_GATE, SECURITY_GATE), + ) + ) + register_connector( + ConnectorProfile( + connector_id="mq", + display_name="Message queue ingestion (DISABLED)", + track=ConnectorTrack.MQ, + vendor_examples=("IBM MQ",), + capabilities=ConnectorCapabilities( + requires_license=True, + requires_certification=False, + requires_pentest=True, + requires_dpa=True, + provides_live_data=False, + ), + licensing_note="Models a queue-based payment message feed. No broker connection is opened.", + compliance_note="Live message feeds carry PII payloads; scrub-before-store posture required.", + security_note="Disabled. No broker credentials read; no socket opened.", + gate_requirements=(LICENSE_GATE, SECURITY_GATE, DPA_GATE), + ) + ) + register_connector( + ConnectorProfile( + connector_id="directory_file_drop", + display_name="Directory file drop / SFTP (DISABLED)", + track=ConnectorTrack.DIRECTORY_FILE_DROP, + vendor_examples=("DTCC ALERT directory",), + capabilities=ConnectorCapabilities( + requires_license=True, + requires_certification=False, + requires_pentest=True, + requires_dpa=True, + provides_live_data=False, + ), + licensing_note="Models a directory file-drop (e.g. ALERT-style SSI directory). No live fetch/SFTP.", + compliance_note="Directory files are licensed; redistribution and retention are restricted.", + security_note="Disabled. No filesystem watch, no SFTP poll, no credentials.", + gate_requirements=(LICENSE_GATE, COMPLIANCE_GATE, SECURITY_GATE, DPA_GATE), + ) + ) + register_connector( + ConnectorProfile( + connector_id="payment_monitor", + display_name="Payment / settlement monitor (DISABLED)", + track=ConnectorTrack.PAYMENT_MONITOR, + vendor_examples=("payment/settlement monitor",), + capabilities=ConnectorCapabilities( + requires_license=True, + requires_certification=False, + requires_pentest=True, + requires_dpa=True, + provides_live_data=False, + ), + licensing_note="Models a payment/settlement status monitor. No live network/settlement polling.", + compliance_note="Settlement status feeds may be MNPI-adjacent; access must be controlled.", + security_note="Disabled. No outbound monitoring calls; nothing polled.", + gate_requirements=(LICENSE_GATE, COMPLIANCE_GATE, SECURITY_GATE, DPA_GATE), + ) + ) + register_connector( + ConnectorProfile( + connector_id="certified_cbpr", + display_name="Certified CBPR+ / cross-border (DISABLED)", + track=ConnectorTrack.CERTIFIED_CBPR, + vendor_examples=("MyStandards / CBPR+ certification",), + capabilities=ConnectorCapabilities( + requires_license=True, + requires_certification=True, + requires_pentest=True, + requires_dpa=True, + provides_live_data=False, + ), + licensing_note="Models CBPR+ certified cross-border message handling. No live network connection.", + compliance_note="CBPR+ requires formal certification/conformance before any production use.", + security_note="Disabled. Certification and pen-test are prerequisites that are NOT met here.", + gate_requirements=(LICENSE_GATE, COMPLIANCE_GATE, CERT_GATE, SECURITY_GATE, DPA_GATE), + ) + ) + + +register_builtin_connectors() diff --git a/apps/payment-intelligence-pilot/app/services/connectors/registry.py b/apps/payment-intelligence-pilot/app/services/connectors/registry.py new file mode 100644 index 0000000..e3329bc --- /dev/null +++ b/apps/payment-intelligence-pilot/app/services/connectors/registry.py @@ -0,0 +1,169 @@ +"""Source-agnostic connector/provider registry — the P3 spine. + +This module is the heart of the P3 (enterprise / live-data readiness) tier. It +describes, in metadata only, the external systems a *future* production build +*could* integrate with (BIC directory, VOP, reachability, MQ, directory file +drops, payment/settlement monitoring, certified CBPR+). + +HARD GATES enforced here: + +* Every connector defaults to ``live_integration = False`` and ``enabled = False``. +* There is NO code path that performs a real network call. The only "adapter" + is :func:`invoke_connector`, a stub that ALWAYS raises + :class:`ConnectorDisabledError` while a connector is disabled or not live — + which is always, by default. +* ``public_dict()`` emits privacy-safe metadata only: no callables, no secrets. + +Enabling a real connector is intentionally impossible from this prototype: it +requires Raf sign-off plus signed licenses, certification, pen-test, and DPA / +residency review (captured per connector in ``gate_requirements``). +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from enum import Enum + + +class ConnectorTrack(str, Enum): + """The class of external system a connector models (all disabled).""" + + BIC_DIRECTORY = "bic_directory" + VOP = "vop" + REACHABILITY = "reachability" + MQ = "mq" + DIRECTORY_FILE_DROP = "directory_file_drop" + PAYMENT_MONITOR = "payment_monitor" + CERTIFIED_CBPR = "certified_cbpr" + + +class ConnectorDisabledError(Exception): + """Raised whenever code attempts to invoke a disabled / non-live connector. + + This is the proof that "disabled connectors cannot be called accidentally". + """ + + def __init__(self, connector_id: str, reason: str) -> None: + self.connector_id = connector_id + self.reason = reason + super().__init__(f"Connector '{connector_id}' is disabled and cannot be invoked: {reason}") + + +@dataclass(frozen=True) +class ConnectorCapabilities: + """Static capability flags. ``live_integration`` is ALWAYS False.""" + + live_integration: bool = False # synthetic/offline only — never claim live + requires_license: bool = True + requires_certification: bool = False + requires_pentest: bool = True + requires_dpa: bool = True + provides_live_data: bool = False # this prototype never provides live data + + +@dataclass(frozen=True) +class ConnectorProfile: + """Privacy-safe, metadata-only description of a (disabled) connector.""" + + connector_id: str + display_name: str + track: ConnectorTrack + vendor_examples: tuple[str, ...] + capabilities: ConnectorCapabilities + licensing_note: str + compliance_note: str + security_note: str + gate_requirements: tuple[str, ...] + # Operational gate state. Both default to the safe/disabled value. + enabled: bool = False + live_integration: bool = False + + def public_dict(self) -> dict[str, object]: + """Privacy-safe metadata for API/UI surfaces. No callables, no secrets.""" + return { + "connector_id": self.connector_id, + "display_name": self.display_name, + "track": self.track.value, + "vendor_examples": list(self.vendor_examples), + "enabled": self.enabled, + "live_integration": self.live_integration, + "status": "DISABLED" if not (self.enabled and self.live_integration) else "LIVE", + "licensing_note": self.licensing_note, + "compliance_note": self.compliance_note, + "security_note": self.security_note, + "gate_requirements": list(self.gate_requirements), + "capabilities": asdict(self.capabilities), + } + + +_REGISTRY: dict[str, ConnectorProfile] = {} + + +def register_connector(profile: ConnectorProfile) -> ConnectorProfile: + """Register (or replace) a connector profile. + + Defends the hard gate: refuses to register anything that claims to be live + or enabled. The prototype must never carry a "live" connector. + """ + if profile.live_integration or profile.capabilities.live_integration: + raise ValueError( + f"Refusing to register connector '{profile.connector_id}' with live_integration=True. " + "This prototype only registers disabled connectors." + ) + if profile.enabled: + raise ValueError( + f"Refusing to register connector '{profile.connector_id}' with enabled=True. " + "This prototype only registers disabled connectors." + ) + _REGISTRY[profile.connector_id] = profile + return profile + + +def get_connector(connector_id: str) -> ConnectorProfile | None: + return _REGISTRY.get(connector_id) + + +def list_connectors() -> list[ConnectorProfile]: + """All registered connectors, ordered by track then id (deterministic).""" + return sorted(_REGISTRY.values(), key=lambda c: (c.track.value, c.connector_id)) + + +def gate_matrix() -> dict[str, object]: + """Privacy-safe gate matrix proving everything is disabled.""" + connectors = [c.public_dict() for c in list_connectors()] + tracks_present = sorted({c.track.value for c in _REGISTRY.values()}) + live_count = sum(1 for c in _REGISTRY.values() if c.enabled and c.live_integration) + return { + "total_connectors": len(connectors), + "tracks": [t.value for t in ConnectorTrack], + "tracks_present": tracks_present, + "tracks_present_count": len(tracks_present), + "enabled_count": sum(1 for c in _REGISTRY.values() if c.enabled), + "live_integration_count": live_count, + "all_disabled": live_count == 0 and all(not c.enabled for c in _REGISTRY.values()), + "auth_notice": "DEMO STUB — NOT PRODUCTION AUTH", + "connectors": connectors, + } + + +def invoke_connector(connector_id: str, *_args: object, **_kwargs: object): + """Stub adapter. ALWAYS raises while the connector is disabled / not live. + + There is deliberately NO branch in this function that performs a network + call. Even if a connector were (impossibly, given ``register_connector``'s + guard) marked enabled+live, this stub still refuses, because the prototype + has no live integration implementation at all. + """ + profile = get_connector(connector_id) + if profile is None: + raise ConnectorDisabledError(connector_id, "unknown connector") + if not profile.enabled: + raise ConnectorDisabledError(connector_id, "connector is not enabled (gated off)") + if not profile.live_integration: + raise ConnectorDisabledError(connector_id, "live_integration is False (no live data path)") + # Unreachable in this prototype: there is no live integration implementation. + raise ConnectorDisabledError( + connector_id, + "no live integration implementation exists in this prototype (requires Raf sign-off, " + "license, certification, pen-test, and DPA/residency review)", + ) diff --git a/apps/payment-intelligence-pilot/app/services/readiness.py b/apps/payment-intelligence-pilot/app/services/readiness.py new file mode 100644 index 0000000..707a808 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/services/readiness.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models import ApprovalRequest, ConnectorState, ReviewItem + +UNRESOLVED_REVIEW = ("open", "assigned") + + +def pilot_readiness(session: Session) -> dict: + """Deterministic, weighted pilot-readiness score (0-100) + band. + + Penalises unresolved high/critical review items, pending approvals, and + connectors that are gated off but would be *required* for a live pilot. The + last term is always present in this prototype because every connector is + disabled by design — so readiness can never falsely read as fully "Green". + """ + review_items = session.query(ReviewItem).all() + open_items = [r for r in review_items if r.status in UNRESOLVED_REVIEW] + critical = sum(1 for r in open_items if r.severity == "critical") + high = sum(1 for r in open_items if r.severity == "high") + medium = sum(1 for r in open_items if r.severity == "medium") + + pending_approvals = session.query(ApprovalRequest).filter(ApprovalRequest.status == "pending").count() + + connectors = session.query(ConnectorState).all() + # In this prototype, every connector is required-but-disabled by definition. + disabled_required_connectors = sum(1 for c in connectors if not (c.enabled and c.live_integration)) + + score = 100.0 + score -= min(40, critical * 10) + score -= min(25, high * 5) + score -= min(10, medium * 1) + score -= min(15, pending_approvals * 3) + score -= min(20, disabled_required_connectors * 2) + score = max(0.0, round(score, 1)) + + if score >= 90: + band = "Green" + elif score >= 75: + band = "Amber" + elif score >= 50: + band = "Red" + else: + band = "Critical" + + return { + "score": score, + "band": band, + "critical_review_items": critical, + "high_review_items": high, + "medium_review_items": medium, + "pending_approvals": pending_approvals, + "disabled_required_connectors": disabled_required_connectors, + "total_connectors": len(connectors), + "live_integrations_enabled": sum(1 for c in connectors if c.enabled and c.live_integration), + "note": ( + "Readiness is gated: all connectors are disabled (live_integration=False), " + "so a live pilot is not certified. Prototype only." + ), + } diff --git a/apps/payment-intelligence-pilot/app/services/review.py b/apps/payment-intelligence-pilot/app/services/review.py new file mode 100644 index 0000000..495e259 --- /dev/null +++ b/apps/payment-intelligence-pilot/app/services/review.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.config import utc_now +from app.errors import raise_app_error +from app.models import AnalysisRecord, ReviewItem +from app.services.audit import stable_id, write_audit + +VALID_SEVERITY = ("low", "medium", "high", "critical") + + +def create_review_item( + session: Session, + *, + analysis_id: str, + severity: str, + title: str, + actor: str, + sla_deadline: str | None = None, +) -> ReviewItem: + if severity not in VALID_SEVERITY: + raise_app_error(400, f"Invalid severity '{severity}'", "INVALID_SEVERITY") + if not session.get(AnalysisRecord, analysis_id): + raise_app_error(404, "Analysis record not found", "ANALYSIS_NOT_FOUND") + now = utc_now() + item = ReviewItem( + id=stable_id("review", analysis_id, severity, title, now), + analysis_id=analysis_id, + severity=severity, + status="open", + owner_email=None, + sla_deadline=sla_deadline, + title=title, + created_at=now, + updated_at=now, + ) + session.add(item) + write_audit( + session, + entity_type="review_item", + entity_id=item.id, + action="review.created", + actor_user_email=actor, + new_value={"severity": severity, "title": title}, + ) + session.commit() + return item + + +def assign_review_item(session: Session, item_id: str, owner_email: str, actor: str) -> ReviewItem: + item = session.get(ReviewItem, item_id) + if not item: + raise_app_error(404, "Review item not found", "REVIEW_ITEM_NOT_FOUND") + before = {"status": item.status, "owner_email": item.owner_email} + item.owner_email = owner_email + item.status = "assigned" + item.updated_at = utc_now() + write_audit( + session, + entity_type="review_item", + entity_id=item_id, + action="review.assigned", + actor_user_email=actor, + previous_value=before, + new_value={"status": "assigned", "owner_email": owner_email}, + ) + session.commit() + return item + + +def resolve_review_item(session: Session, item_id: str, actor: str, reason: str = "Resolved") -> ReviewItem: + item = session.get(ReviewItem, item_id) + if not item: + raise_app_error(404, "Review item not found", "REVIEW_ITEM_NOT_FOUND") + before = {"status": item.status} + item.status = "resolved" + item.updated_at = utc_now() + write_audit( + session, + entity_type="review_item", + entity_id=item_id, + action="review.resolved", + actor_user_email=actor, + previous_value=before, + new_value={"status": "resolved", "reason": reason}, + ) + session.commit() + return item diff --git a/apps/payment-intelligence-pilot/pyproject.toml b/apps/payment-intelligence-pilot/pyproject.toml new file mode 100644 index 0000000..51972ab --- /dev/null +++ b/apps/payment-intelligence-pilot/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "payment-intelligence-pilot" +version = "0.1.0" +description = "Honest, gated P2/P3 prototype for a payment-intelligence workbench (synthetic, offline, local-only)" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.111", + "uvicorn[standard]>=0.29", + "pydantic>=2.7", + "sqlalchemy>=2.0", + "pyyaml>=6.0", + "python-multipart>=0.0.9", + "httpx>=0.27", +] + +[project.optional-dependencies] +test = ["pytest>=8.2"] + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/apps/payment-intelligence-pilot/tests/conftest.py b/apps/payment-intelligence-pilot/tests/conftest.py new file mode 100644 index 0000000..b3dec4d --- /dev/null +++ b/apps/payment-intelligence-pilot/tests/conftest.py @@ -0,0 +1,28 @@ +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + db_path = tmp_path / "payment_intelligence_pilot.db" + monkeypatch.setenv("PIP_DB_PATH", str(db_path)) + monkeypatch.setenv("PIP_EXPORT_DIR", str(tmp_path / "exports")) + import app.db as db + db.reset_engine() + from app.main import create_app + app = create_app() + with TestClient(app) as test_client: + yield test_client + db.reset_engine() + + +@pytest.fixture() +def db_session(client): + from app.db import SessionLocal + with SessionLocal() as session: + yield session + + +def actor(email: str) -> dict[str, str]: + """DEMO STUB header helper — selects role purely from a trusted email header.""" + return {"X-User-Email": email} diff --git a/apps/payment-intelligence-pilot/tests/test_analyses_privacy.py b/apps/payment-intelligence-pilot/tests/test_analyses_privacy.py new file mode 100644 index 0000000..58b0820 --- /dev/null +++ b/apps/payment-intelligence-pilot/tests/test_analyses_privacy.py @@ -0,0 +1,54 @@ +from tests.conftest import actor + + +def test_unmasked_account_number_rejected(client): + body = { + "module": "iso20022-structure", + "message_family": "pacs.008", + "result_status": "findings", + "finding_count": 1, + "summary": "debtor account 12345678901234 flagged", # raw account-like digits + } + resp = client.post("/api/v1/analyses", json=body, headers=actor("analyst@example.com")) + assert resp.status_code == 400 + assert resp.json()["code"] == "UNMASKED_ACCOUNT_FIELD" + + +def test_unmasked_iban_in_reference_rejected(client): + body = { + "module": "recon", + "message_family": "camt.053", + "result_status": "clean", + "finding_count": 0, + "summary": "ok", + "masked_reference": "GB29NWBK60161331926819", # IBAN-like, unmasked + } + resp = client.post("/api/v1/analyses", json=body, headers=actor("analyst@example.com")) + assert resp.status_code == 400 + assert resp.json()["code"] == "UNMASKED_ACCOUNT_FIELD" + + +def test_masked_reference_accepted(client): + body = { + "module": "recon", + "message_family": "camt.053", + "result_status": "clean", + "finding_count": 0, + "summary": "clean run for TESTGB2LXXX", + "masked_reference": "****1111", + } + resp = client.post("/api/v1/analyses", json=body, headers=actor("analyst@example.com")) + assert resp.status_code == 201 + assert resp.json()["masked_reference"] == "****1111" + + +def test_listing_carries_synthetic_notice_and_no_raw_payload(client): + body = client.get("/api/v1/analyses").json() + assert "synthetic" in body["synthetic_notice"].lower() + for item in body["items"]: + # Aggregate fields only; no payload/raw blob fields exist on the record. + assert set(item.keys()) <= { + "id", "module", "message_family", "result_status", "finding_count", + "summary", "masked_reference", "synthetic_notice", "created_at", + } + assert "payload" not in item diff --git a/apps/payment-intelligence-pilot/tests/test_approvals_four_eyes.py b/apps/payment-intelligence-pilot/tests/test_approvals_four_eyes.py new file mode 100644 index 0000000..2ad90a2 --- /dev/null +++ b/apps/payment-intelligence-pilot/tests/test_approvals_four_eyes.py @@ -0,0 +1,44 @@ +from tests.conftest import actor + + +def _submit(client, requester="ops-owner@example.com"): + body = {"subject": "Promote synthetic ruleset", "change_summary": "synthetic change", "risk_level": "high"} + resp = client.post("/api/v1/approvals", json=body, headers=actor(requester)) + assert resp.status_code == 201 + return resp.json()["id"] + + +def test_self_approval_blocked(client): + approval_id = _submit(client, requester="ops-owner@example.com") + resp = client.post( + f"/api/v1/approvals/{approval_id}/approve", + json={"reason": "self"}, + headers=actor("ops-owner@example.com"), + ) + assert resp.status_code == 403 + assert resp.json()["code"] == "SELF_APPROVAL_REJECTED" + + +def test_four_eyes_flow_and_audit(client): + approval_id = _submit(client, requester="ops-owner@example.com") + resp = client.post( + f"/api/v1/approvals/{approval_id}/approve", + json={"reason": "looks good"}, + headers=actor("approver@example.com"), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "approved" + assert body["decided_by"] == "approver@example.com" + + audit = client.get("/api/v1/audit").json() + actions = [e["action"] for e in audit["items"]] + assert "approval.submitted" in actions + assert "approval.approved" in actions + + +def test_double_decision_conflicts(client): + approval_id = _submit(client) + client.post(f"/api/v1/approvals/{approval_id}/approve", json={"reason": "ok"}, headers=actor("approver@example.com")) + resp = client.post(f"/api/v1/approvals/{approval_id}/reject", json={"reason": "late"}, headers=actor("risk@example.com")) + assert resp.status_code == 409 diff --git a/apps/payment-intelligence-pilot/tests/test_audit_immutability.py b/apps/payment-intelligence-pilot/tests/test_audit_immutability.py new file mode 100644 index 0000000..83e3b43 --- /dev/null +++ b/apps/payment-intelligence-pilot/tests/test_audit_immutability.py @@ -0,0 +1,38 @@ +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DatabaseError # parent of IntegrityError/OperationalError + +from tests.conftest import actor + + +def _seed_one_audit_row(client): + # Any state-changing call writes an audit event. + client.post( + "/api/v1/analyses", + json={ + "module": "recon", + "message_family": "camt.053", + "result_status": "clean", + "finding_count": 0, + "summary": "synthetic", + }, + headers=actor("admin@example.com"), + ) + + +def test_update_audit_event_raises(client): + _seed_one_audit_row(client) + from app.db import SessionLocal + with SessionLocal() as session: + with pytest.raises(DatabaseError): + session.execute(text("UPDATE audit_events SET action = 'tampered'")) + session.commit() + + +def test_delete_audit_event_raises(client): + _seed_one_audit_row(client) + from app.db import SessionLocal + with SessionLocal() as session: + with pytest.raises(DatabaseError): + session.execute(text("DELETE FROM audit_events")) + session.commit() diff --git a/apps/payment-intelligence-pilot/tests/test_auth_stub.py b/apps/payment-intelligence-pilot/tests/test_auth_stub.py new file mode 100644 index 0000000..2b19ca3 --- /dev/null +++ b/apps/payment-intelligence-pilot/tests/test_auth_stub.py @@ -0,0 +1,39 @@ +from tests.conftest import actor + + +def test_x_user_email_selects_role_via_audit(client): + """The trusted header maps to a seeded demo role (recorded in audit).""" + # admin actor creates an analysis; audit should record actor_role=admin. + payload = { + "module": "iso20022-structure", + "message_family": "pacs.008", + "result_status": "clean", + "finding_count": 0, + "summary": "synthetic clean run", + } + resp = client.post("/api/v1/analyses", json=payload, headers=actor("admin@example.com")) + assert resp.status_code == 201 + audit = client.get("/api/v1/audit").json() + created = [e for e in audit["items"] if e["action"] == "analysis.created"] + assert created and created[-1]["actor_role"] == "admin" + assert created[-1]["actor_user_email"] == "admin@example.com" + + +def test_unknown_actor_is_system_role(client): + payload = { + "module": "recon", + "message_family": "camt.053", + "result_status": "clean", + "finding_count": 0, + "summary": "synthetic", + } + resp = client.post("/api/v1/analyses", json=payload, headers=actor("stranger@example.com")) + assert resp.status_code == 201 + audit = client.get("/api/v1/audit").json() + assert audit["items"][-1]["actor_role"] == "system" + + +def test_responses_label_demo_stub(client): + for path in ("/", "/health", "/api/v1/dashboard/operator-summary"): + body = client.get(path).json() + assert "DEMO STUB" in body["auth"], path diff --git a/apps/payment-intelligence-pilot/tests/test_connector_disabled_guard.py b/apps/payment-intelligence-pilot/tests/test_connector_disabled_guard.py new file mode 100644 index 0000000..7b03bf4 --- /dev/null +++ b/apps/payment-intelligence-pilot/tests/test_connector_disabled_guard.py @@ -0,0 +1,32 @@ +import pytest + +from app.services.connectors.registry import ( + ConnectorDisabledError, + invoke_connector, + list_connectors, +) + + +def test_invoke_connector_always_raises_for_every_connector(): + """The 'disabled connectors cannot be called accidentally' proof (service layer).""" + for c in list_connectors(): + with pytest.raises(ConnectorDisabledError): + invoke_connector(c.connector_id) + + +def test_invoke_unknown_connector_raises(): + with pytest.raises(ConnectorDisabledError): + invoke_connector("does-not-exist") + + +def test_post_invoke_never_returns_200(client): + """Every POST /invoke must error (409), never succeed.""" + listing = client.get("/api/v1/connectors").json() + assert listing["connectors"], "expected connectors to be registered" + for c in listing["connectors"]: + resp = client.post(f"/api/v1/connectors/{c['connector_id']}/invoke") + assert resp.status_code == 409, c["connector_id"] + body = resp.json() + assert body["code"] == "CONNECTOR_DISABLED" + assert "disabled" in body["detail"].lower() + assert resp.status_code != 200 diff --git a/apps/payment-intelligence-pilot/tests/test_connector_registry.py b/apps/payment-intelligence-pilot/tests/test_connector_registry.py new file mode 100644 index 0000000..d930a29 --- /dev/null +++ b/apps/payment-intelligence-pilot/tests/test_connector_registry.py @@ -0,0 +1,48 @@ +from app.services.connectors.registry import ( + ConnectorTrack, + gate_matrix, + list_connectors, +) + + +def test_every_connector_is_disabled_and_not_live(): + connectors = list_connectors() + assert connectors, "registry must not be empty" + for c in connectors: + assert c.live_integration is False, c.connector_id + assert c.enabled is False, c.connector_id + assert c.capabilities.live_integration is False, c.connector_id + assert c.capabilities.provides_live_data is False, c.connector_id + + +def test_all_seven_tracks_present(): + tracks = {c.track for c in list_connectors()} + assert tracks == set(ConnectorTrack) + assert len(ConnectorTrack) == 7 + + +def test_gate_matrix_reports_zero_live_integrations(): + gm = gate_matrix() + assert gm["live_integration_count"] == 0 + assert gm["enabled_count"] == 0 + assert gm["all_disabled"] is True + assert gm["tracks_present_count"] == 7 + + +def test_public_dict_has_no_callables_or_secrets(): + for c in list_connectors(): + d = c.public_dict() + for value in d.values(): + assert not callable(value) + assert d["status"] == "DISABLED" + assert d["gate_requirements"], c.connector_id + + +def test_api_connectors_listing_all_disabled(client): + body = client.get("/api/v1/connectors").json() + assert body["all_disabled"] is True + assert body["live_integration_count"] == 0 + for c in body["connectors"]: + assert c["enabled"] is False + assert c["live_integration"] is False + assert c["status"] == "DISABLED" diff --git a/apps/payment-intelligence-pilot/tests/test_health.py b/apps/payment-intelligence-pilot/tests/test_health.py new file mode 100644 index 0000000..6b828e6 --- /dev/null +++ b/apps/payment-intelligence-pilot/tests/test_health.py @@ -0,0 +1,20 @@ +def test_health_is_honest_and_no_live_integrations(client): + for path in ("/health", "/api/v1/health"): + resp = client.get(path) + assert resp.status_code == 200, path + body = resp.json() + assert body["status"] == "ok" + assert body["service"] == "payment-intelligence-pilot" + assert body["version"] == "0.1.0" + assert body["environment"] == "prototype" + assert body["db_ok"] is True + # The core honesty gate: zero live integrations. + assert body["live_integrations_enabled"] == 0 + assert "DEMO STUB" in body["auth"] + assert "disclaimer" in body and body["disclaimer"] + + +def test_root_advertises_demo_stub_and_prototype(client): + body = client.get("/").json() + assert body["environment"] == "prototype" + assert "DEMO STUB" in body["auth"] diff --git a/apps/payment-intelligence-pilot/tests/test_operator_summary.py b/apps/payment-intelligence-pilot/tests/test_operator_summary.py new file mode 100644 index 0000000..427b64d --- /dev/null +++ b/apps/payment-intelligence-pilot/tests/test_operator_summary.py @@ -0,0 +1,41 @@ +from tests.conftest import actor + + +def test_operator_summary_is_aggregate_only(client): + body = client.get("/api/v1/dashboard/operator-summary").json() + assert body["environment"] == "prototype" + assert "DEMO STUB" in body["auth"] + + rq = body["review_queue"] + assert set(rq.keys()) == {"total", "by_severity", "by_status"} + # counts only — every value is an int + for v in rq["by_severity"].values(): + assert isinstance(v, int) + for v in rq["by_status"].values(): + assert isinstance(v, int) + + assert "pending" in body["approvals"] + assert body["connector_gates"]["all_disabled"] is True + assert body["connector_gates"]["live_integration_count"] == 0 + + readiness = body["readiness"] + assert "score" in readiness and "band" in readiness + assert readiness["live_integrations_enabled"] == 0 + + +def test_summary_reflects_open_review_items(client): + # create an analysis then a high-severity review item + a = client.post( + "/api/v1/analyses", + json={"module": "m", "message_family": "pacs.008", "result_status": "findings", "finding_count": 2, "summary": "synthetic"}, + headers=actor("analyst@example.com"), + ).json() + client.post( + "/api/v1/review", + json={"analysis_id": a["id"], "severity": "high", "title": "synthetic finding"}, + headers=actor("ops-owner@example.com"), + ) + body = client.get("/api/v1/dashboard/operator-summary").json() + assert body["review_queue"]["by_severity"].get("high", 0) >= 1 + # readiness should be penalised below a perfect Green due to gated connectors + assert body["readiness"]["score"] < 100 diff --git a/e2e/workbench.spec.ts b/e2e/workbench.spec.ts new file mode 100644 index 0000000..c644dce --- /dev/null +++ b/e2e/workbench.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; + +const NEW_ROUTES = [ + "/workbench", + "/scenarios", + "/review", + "/analytics", + "/vault", + "/health", + "/docs", + "/connectors", + "/pilot", +]; + +test("new surfaces render and preserve the privacy boundary", async ({ context, page }) => { + const requestedUrls: string[] = []; + page.on("request", (request) => requestedUrls.push(request.url())); + + for (const route of NEW_ROUTES) { + await page.goto(route); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + } + + const origin = new URL(page.url()).origin; + const externalRequests = requestedUrls.filter((url) => { + const parsed = new URL(url); + return parsed.protocol.startsWith("http") && parsed.origin !== origin; + }); + expect(externalRequests).toEqual([]); + + const persistence = await page.evaluate(() => ({ + localStorageLength: localStorage.length, + sessionStorageLength: sessionStorage.length, + cookie: document.cookie, + })); + expect(persistence).toEqual({ localStorageLength: 0, sessionStorageLength: 0, cookie: "" }); + await expect.poll(async () => (await context.cookies()).length).toBe(0); +}); + +test("connectors page proves a disabled connector cannot be invoked", async ({ page }) => { + await page.goto("/connectors"); + await page + .getByRole("button", { name: /attempt invoke/i }) + .first() + .click(); + await expect(page.getByText(/Blocked:/i).first()).toBeVisible(); +}); + +test("scenario switcher loads a synthetic message into the in-memory workbench", async ({ + page, +}) => { + await page.goto("/scenarios"); + await page.getByRole("button", { name: /load into workbench/i }).click(); + await expect(page).toHaveURL(/\/workbench/); + await expect(page.getByText(/session documents/i)).toBeVisible(); +}); diff --git a/src/App.tsx b/src/App.tsx index f47f1e8..1799207 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,29 +1,50 @@ import { Route, Routes } from "react-router-dom"; import { SuiteLayout } from "@/components/layout/SuiteLayout"; +import { SessionProvider } from "@/lib/workbench/session"; +import { AnalyticsPage } from "@/pages/AnalyticsPage"; import { BicPage } from "@/pages/BicPage"; import { CbprPage } from "@/pages/CbprPage"; +import { ConnectorsPage } from "@/pages/ConnectorsPage"; +import { DocsPage } from "@/pages/DocsPage"; +import { HealthPage } from "@/pages/HealthPage"; import { HomePage } from "@/pages/HomePage"; import { IbanPage } from "@/pages/IbanPage"; import { InsightsPage } from "@/pages/InsightsPage"; import { NotFoundPage } from "@/pages/NotFoundPage"; +import { PilotPage } from "@/pages/PilotPage"; +import { ReviewQueuePage } from "@/pages/ReviewQueuePage"; +import { ScenariosPage } from "@/pages/ScenariosPage"; import { ScrubberPage } from "@/pages/ScrubberPage"; import { SsiPage } from "@/pages/SsiPage"; import { StorytellerPage } from "@/pages/StorytellerPage"; +import { VaultPage } from "@/pages/VaultPage"; +import { WorkbenchPage } from "@/pages/WorkbenchPage"; export function App() { return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); } diff --git a/src/App.workbench.test.tsx b/src/App.workbench.test.tsx new file mode 100644 index 0000000..9bd34e5 --- /dev/null +++ b/src/App.workbench.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { App } from "./App"; + +function renderAt(path: string) { + return render( + + + , + ); +} + +const ROUTES: [string, RegExp][] = [ + ["/workbench", /workbench command center/i], + ["/scenarios", /scenario switcher/i], + ["/review", /review queue/i], + ["/analytics", /session analytics/i], + ["/vault", /local vault/i], + ["/health", /health & scope/i], + ["/docs", /docs, methodology & support/i], + ["/connectors", /connector readiness/i], + ["/pilot", /private-pilot scaffold/i], +]; + +describe("App — workbench & platform routes", () => { + it.each(ROUTES)("renders %s", (path, heading) => { + renderAt(path); + expect(screen.getByRole("heading", { level: 1, name: heading })).toBeInTheDocument(); + }); + + it("keeps SSI unpromoted on the home launcher", () => { + renderAt("/"); + expect(screen.queryAllByRole("link", { name: /ssi/i })).toHaveLength(0); + }); +}); diff --git a/src/components/layout/SuiteFooter.tsx b/src/components/layout/SuiteFooter.tsx index f8af1b6..5367754 100644 --- a/src/components/layout/SuiteFooter.tsx +++ b/src/components/layout/SuiteFooter.tsx @@ -1,18 +1,34 @@ import { ShieldCheck } from "lucide-react"; +import { Link } from "react-router-dom"; import { SUITE_VERSION } from "@/version"; +import { surfacesByGroup } from "@/lib/workbench/surfaces"; + +const platformLinks = [...surfacesByGroup("workbench"), ...surfacesByGroup("platform")]; export function SuiteFooter() { return ( ); diff --git a/src/components/layout/SuiteHeader.tsx b/src/components/layout/SuiteHeader.tsx index 6d1edc5..939dcea 100644 --- a/src/components/layout/SuiteHeader.tsx +++ b/src/components/layout/SuiteHeader.tsx @@ -10,12 +10,14 @@ interface NavItem { const navItems: NavItem[] = [ { to: "/", label: "Overview", end: true }, + { to: "/workbench", label: "Workbench", end: false }, { to: "/scrubber", label: "Scrubber", end: false }, { to: "/storyteller", label: "Storyteller", end: false }, { to: "/iban", label: "IBAN", end: false }, { to: "/bic", label: "BIC*", end: false }, { to: "/cbpr", label: "CBPR+", end: false }, { to: "/insights", label: "Insights", end: false }, + { to: "/docs", label: "Docs", end: false }, ]; export function SuiteHeader() { diff --git a/src/components/workbench/CapabilityBadge.tsx b/src/components/workbench/CapabilityBadge.tsx new file mode 100644 index 0000000..959c38d --- /dev/null +++ b/src/components/workbench/CapabilityBadge.tsx @@ -0,0 +1,38 @@ +import { cn } from "@/lib/utils"; +import { CAPABILITY_LABEL, MATURITY_LABEL } from "@/lib/workbench/taxonomy"; +import type { CapabilityState, MaturityTier } from "@/lib/workbench/types"; + +const CAPABILITY_CLASS: Record = { + available: "border-brand/25 bg-brand/10 text-primary", + demo: "border-amber-300/60 bg-amber-100/70 text-amber-900", + local: "border-accent/30 bg-accent/10 text-accent-foreground", + prototype: "border-indigo-300/60 bg-indigo-100/70 text-indigo-900", + gated: "border-slate-300 bg-slate-100 text-slate-700", + planned: "border-border bg-muted text-muted-foreground", +}; + +interface CapabilityBadgeProps { + capability: CapabilityState; + maturity?: MaturityTier; + className?: string; +} + +export function CapabilityBadge({ capability, maturity, className }: CapabilityBadgeProps) { + return ( + + + {CAPABILITY_LABEL[capability]} + + {maturity ? ( + + {MATURITY_LABEL[maturity].split(" · ")[0]} + + ) : null} + + ); +} diff --git a/src/components/workbench/CaveatPanel.tsx b/src/components/workbench/CaveatPanel.tsx new file mode 100644 index 0000000..787068d --- /dev/null +++ b/src/components/workbench/CaveatPanel.tsx @@ -0,0 +1,36 @@ +import { AlertTriangle } from "lucide-react"; + +interface CaveatPanelProps { + title?: string; + points: string[]; +} + +/** + * Shared "scope / what this does not do" panel. Used to keep anti-overclaim + * caveats visually consistent across modules and platform surfaces. + */ +export function CaveatPanel({ title = "Scope & limitations", points }: CaveatPanelProps) { + if (points.length === 0) return null; + return ( +
+
+
+
    + {points.map((point) => ( +
  • +
  • + ))} +
+
+ ); +} diff --git a/src/components/workbench/FindingsList.tsx b/src/components/workbench/FindingsList.tsx new file mode 100644 index 0000000..3e76588 --- /dev/null +++ b/src/components/workbench/FindingsList.tsx @@ -0,0 +1,36 @@ +import { cn } from "@/lib/utils"; +import { SEVERITY_LABEL } from "@/lib/workbench/taxonomy"; +import type { Finding, Severity } from "@/lib/workbench/types"; + +const SEVERITY_CLASS: Record = { + pass: "border-emerald-300/60 bg-emerald-50 text-emerald-900", + info: "border-sky-300/60 bg-sky-50 text-sky-900", + warning: "border-amber-300/60 bg-amber-50 text-amber-900", + critical: "border-red-300/60 bg-red-50 text-red-900", +}; + +export function FindingsList({ findings }: { findings: Finding[] }) { + if (findings.length === 0) { + return

No findings in this session yet.

; + } + return ( +
    + {findings.map((finding) => ( +
  • +
    + + {SEVERITY_LABEL[finding.severity]} + + {finding.field ? ( + {finding.field} + ) : null} +
    +

    {finding.message}

    +
  • + ))} +
+ ); +} diff --git a/src/components/workbench/GateChecklist.tsx b/src/components/workbench/GateChecklist.tsx new file mode 100644 index 0000000..f1bc1f9 --- /dev/null +++ b/src/components/workbench/GateChecklist.tsx @@ -0,0 +1,31 @@ +import { Lock } from "lucide-react"; + +interface GateChecklistProps { + requirements: string[]; + /** When true (the default) every gate renders as locked/unmet. */ + locked?: boolean; +} + +/** + * Renders gate requirements for a disabled connector / gated surface. By + * design every item shows as unmet — these gates are not satisfied here. + */ +export function GateChecklist({ requirements, locked = true }: GateChecklistProps) { + return ( +
    + {requirements.map((requirement) => ( +
  • +
  • + ))} +
+ ); +} diff --git a/src/components/workbench/MaturityLegend.tsx b/src/components/workbench/MaturityLegend.tsx new file mode 100644 index 0000000..b753663 --- /dev/null +++ b/src/components/workbench/MaturityLegend.tsx @@ -0,0 +1,19 @@ +import { MATURITY_DESCRIPTION, MATURITY_LABEL } from "@/lib/workbench/taxonomy"; +import type { MaturityTier } from "@/lib/workbench/types"; + +const TIERS: MaturityTier[] = ["P0", "P1", "P2", "P3"]; + +export function MaturityLegend() { + return ( +
+ {TIERS.map((tier) => ( +
+
{MATURITY_LABEL[tier]}
+
+ {MATURITY_DESCRIPTION[tier]} +
+
+ ))} +
+ ); +} diff --git a/src/components/workbench/ProvenanceCard.tsx b/src/components/workbench/ProvenanceCard.tsx new file mode 100644 index 0000000..6042335 --- /dev/null +++ b/src/components/workbench/ProvenanceCard.tsx @@ -0,0 +1,68 @@ +import { CheckCircle2, XCircle } from "lucide-react"; +import type { Provenance } from "@/lib/workbench/types"; + +export function ProvenanceCard({ provenance }: { provenance: Provenance }) { + return ( +
+
+

Provenance

+ + Live: No + +
+
+
+
Source:
+
{provenance.source}
+
+
+
Method:
+
{provenance.method}
+
+ {provenance.freshness ? ( +
+
Freshness:
+
{provenance.freshness}
+
+ ) : null} +
+
+
+

+ Checked +

+
    + {provenance.checked.map((item) => ( +
  • +
  • + ))} +
+
+
+

+ Not checked +

+
    + {provenance.notChecked.map((item) => ( +
  • +
  • + ))} +
+
+
+
+ ); +} diff --git a/src/lib/workbench/analytics.test.ts b/src/lib/workbench/analytics.test.ts new file mode 100644 index 0000000..1136352 --- /dev/null +++ b/src/lib/workbench/analytics.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { computeAnalytics } from "./analytics"; +import type { ModuleResult, Provenance } from "./types"; + +const provenance: Provenance = { + source: "synthetic", + method: "synthetic-fixture", + checked: [], + notChecked: [], + live: false, +}; + +const results: ModuleResult[] = [ + { + moduleId: "storyteller", + documentId: "d1", + resultStatus: "pass", + findings: [{ id: "ok", severity: "pass", message: "ok" }], + provenance, + limitations: [], + }, + { + moduleId: "insights", + documentId: "d2", + resultStatus: "warning", + findings: [ + { id: "r", severity: "warning", field: "RtrRsnInf/Rsn/Cd", message: "w" }, + { id: "i", severity: "info", message: "i" }, + ], + provenance, + limitations: [], + }, +]; + +describe("computeAnalytics", () => { + it("aggregates severities, pass-rate, modules and reason codes", () => { + const analytics = computeAnalytics(results, 2); + expect(analytics.totalResults).toBe(2); + expect(analytics.totalDocuments).toBe(2); + expect(analytics.totalFindings).toBe(3); + expect(analytics.bySeverity).toEqual({ pass: 1, info: 1, warning: 1, critical: 0 }); + expect(analytics.passRate).toBe(0.5); + expect(analytics.byModule).toHaveLength(2); + expect(analytics.reasonCodes[0]?.count).toBeGreaterThan(0); + }); + + it("treats an empty session as fully passing", () => { + const analytics = computeAnalytics([]); + expect(analytics.passRate).toBe(1); + expect(analytics.totalResults).toBe(0); + }); +}); diff --git a/src/lib/workbench/analytics.ts b/src/lib/workbench/analytics.ts new file mode 100644 index 0000000..e8f0097 --- /dev/null +++ b/src/lib/workbench/analytics.ts @@ -0,0 +1,87 @@ +import { SEVERITY_RANK } from "./taxonomy"; +import type { ModuleResult, Severity } from "./types"; + +export interface SeverityTally { + pass: number; + info: number; + warning: number; + critical: number; +} + +export interface ReasonCode { + code: string; + count: number; +} + +export interface ModuleBreakdown { + moduleId: string; + results: number; + findings: number; +} + +export interface SessionAnalytics { + totalDocuments: number; + totalResults: number; + totalFindings: number; + bySeverity: SeverityTally; + byModule: ModuleBreakdown[]; + /** Fraction (0..1) of results whose status is pass or info. */ + passRate: number; + reasonCodes: ReasonCode[]; +} + +/** + * Compute analytics over the analyses run in this session. Pure and + * synchronous — no storage, no telemetry, no network. + */ +export function computeAnalytics( + results: readonly ModuleResult[], + documentCount = 0, +): SessionAnalytics { + const bySeverity: SeverityTally = { pass: 0, info: 0, warning: 0, critical: 0 }; + const moduleMap = new Map(); + const reasonMap = new Map(); + + let totalFindings = 0; + let passing = 0; + + for (const result of results) { + if (SEVERITY_RANK[result.resultStatus] <= SEVERITY_RANK.info) { + passing += 1; + } + + const moduleEntry = moduleMap.get(result.moduleId) ?? { + moduleId: result.moduleId, + results: 0, + findings: 0, + }; + moduleEntry.results += 1; + moduleEntry.findings += result.findings.length; + moduleMap.set(result.moduleId, moduleEntry); + + for (const finding of result.findings) { + totalFindings += 1; + bySeverity[finding.severity] += 1; + const code = finding.field ?? finding.id; + reasonMap.set(code, (reasonMap.get(code) ?? 0) + 1); + } + } + + const reasonCodes: ReasonCode[] = [...reasonMap.entries()] + .map(([code, count]) => ({ code, count })) + .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code)); + + return { + totalDocuments: documentCount, + totalResults: results.length, + totalFindings, + bySeverity, + byModule: [...moduleMap.values()].sort((a, b) => b.results - a.results), + passRate: results.length === 0 ? 1 : passing / results.length, + reasonCodes, + }; +} + +export function severityList(): Severity[] { + return ["critical", "warning", "info", "pass"]; +} diff --git a/src/lib/workbench/connectors.test.ts b/src/lib/workbench/connectors.test.ts new file mode 100644 index 0000000..78dcf80 --- /dev/null +++ b/src/lib/workbench/connectors.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + CONNECTORS, + ConnectorDisabledError, + gateMatrix, + getConnector, + invokeConnector, +} from "./connectors"; + +describe("connector registry (P3, disabled)", () => { + it("registers the seven tracks, all disabled", () => { + const matrix = gateMatrix(); + expect(matrix.total).toBe(7); + expect(matrix.liveEnabled).toBe(0); + expect(matrix.disabled).toBe(7); + expect(new Set(matrix.tracks).size).toBe(7); + }); + + it("hard-disables live integration and enablement on every connector", () => { + for (const connector of CONNECTORS) { + expect(connector.liveIntegration).toBe(false); + expect(connector.enabled).toBe(false); + expect(connector.gateRequirements.length).toBeGreaterThan(0); + } + }); + + it("cannot invoke any connector — the guard always throws", () => { + for (const connector of CONNECTORS) { + expect(() => invokeConnector(connector.id)).toThrow(ConnectorDisabledError); + } + }); + + it("resolves known connectors and returns null for unknown ids", () => { + expect(getConnector("vop")?.track).toBe("VOP"); + expect(getConnector("does-not-exist")).toBeNull(); + }); +}); diff --git a/src/lib/workbench/connectors.ts b/src/lib/workbench/connectors.ts new file mode 100644 index 0000000..2ec5df9 --- /dev/null +++ b/src/lib/workbench/connectors.ts @@ -0,0 +1,216 @@ +// P3 live-data connector framework — front-end mirror of the disabled registry. +// +// This is the honest, gated representation of the enterprise/live-data tracks. +// EVERY connector is disabled: `liveIntegration` and `enabled` are hard-typed +// `false`. There is no code path that performs a real provider call. The only +// "invoke" entry point, `invokeConnector`, ALWAYS throws — proving a disabled +// connector cannot be called accidentally from the browser suite. +// +// The companion offline FastAPI scaffold (apps/payment-intelligence-pilot) +// enforces the same invariant server-side with its own registry + tests. + +export type ConnectorTrack = + | "BIC_DIRECTORY" + | "VOP" + | "REACHABILITY" + | "MQ" + | "DIRECTORY_FILE_DROP" + | "PAYMENT_MONITOR" + | "CERTIFIED_CBPR"; + +export interface ConnectorProfile { + id: string; + track: ConnectorTrack; + trackLabel: string; + displayName: string; + vendorExamples: string[]; + description: string; + liveIntegration: false; + enabled: false; + gateRequirements: string[]; + licensingNote: string; + complianceNote: string; + securityNote: string; + maturity: "P3"; +} + +export const CONNECTORS: readonly ConnectorProfile[] = [ + { + id: "bic-directory", + track: "BIC_DIRECTORY", + trackLabel: "BIC directory", + displayName: "Live BIC directory lookup", + vendorExamples: ["SWIFTRef / BIC Directory", "SwiftRef SSI Plus"], + description: + "Resolve and enrich BICs against a licensed live directory instead of a bundled snapshot.", + liveIntegration: false, + enabled: false, + gateRequirements: [ + "Signed SWIFTRef / directory data license", + "Compliance sign-off on redistribution", + "Security review of egress path", + ], + licensingNote: "Requires a commercial SWIFTRef license. Not bundled.", + complianceNote: "Directory redistribution terms must be cleared before enabling.", + securityNote: "Outbound lookup is a new data-egress surface; pen-test required.", + maturity: "P3", + }, + { + id: "vop", + track: "VOP", + trackLabel: "Verification of Payee", + displayName: "Verification of Payee (name matching)", + vendorExamples: ["EPC VOP scheme", "Bank/aggregator VOP API"], + description: "Match payee name to account under the EPC VOP scheme.", + liveIntegration: false, + enabled: false, + gateRequirements: [ + "VOP scheme adherence / certification", + "Data-protection assessment (name + account matching)", + "Security review", + ], + licensingNote: "VOP routing requires scheme participation.", + complianceNote: "Name-matching processes personal data — DPA and lawful basis required.", + securityNote: "Handles account + name pairs; encryption-in-transit and at-rest required.", + maturity: "P3", + }, + { + id: "reachability", + track: "REACHABILITY", + trackLabel: "Scheme reachability", + displayName: "SEPA + FIN reachability", + vendorExamples: ["EPC routing tables", "SWIFT FIN reachability"], + description: "Determine whether a BIC/scheme combination is reachable for a given rail.", + liveIntegration: false, + enabled: false, + gateRequirements: [ + "Licensed reachability data feed", + "Freshness / staleness SLA agreed", + "Compliance sign-off", + ], + licensingNote: "Reachability tables are licensed reference data.", + complianceNote: "Stale reachability data can mis-route — operational liability gate.", + securityNote: "Feed ingestion must be integrity-checked.", + maturity: "P3", + }, + { + id: "mq", + track: "MQ", + trackLabel: "Message queue", + displayName: "IBM MQ connector", + vendorExamples: ["IBM MQ", "Bank middleware"], + description: "Ingest messages from an enterprise MQ instead of file paste/upload.", + liveIntegration: false, + enabled: false, + gateRequirements: [ + "Credential vaulting designed and reviewed (no plaintext)", + "Network architecture review", + "Security sign-off / pen-test", + ], + licensingNote: "Enterprise middleware; deployment-specific.", + complianceNote: "Ingests live operational traffic — data-residency decision required.", + securityNote: "Credentials must be vaulted, never stored in plaintext or shown in UI.", + maturity: "P3", + }, + { + id: "directory-file-drop", + track: "DIRECTORY_FILE_DROP", + trackLabel: "Directory / file drop", + displayName: "Directory file-drop connector", + vendorExamples: ["SFTP drop", "DTCC ALERT directory export"], + description: "Watch a directory / SFTP drop for batch files to ingest.", + liveIntegration: false, + enabled: false, + gateRequirements: [ + "Security review of file-system / SFTP access", + "Scrub-before-store policy", + "No dev paths or env config leaked in UI", + ], + licensingNote: "Source-system dependent.", + complianceNote: "Batch files may carry raw PII — scrub-before-store required.", + securityNote: "Filesystem credentials and paths must never surface in the UI.", + maturity: "P3", + }, + { + id: "payment-monitor", + track: "PAYMENT_MONITOR", + trackLabel: "Payment monitoring", + displayName: "Live payment / settlement monitor", + vendorExamples: ["Scheme tracker feeds", "Settlement monitoring"], + description: "Stream live payment/settlement status instead of grouping user-supplied files.", + liveIntegration: false, + enabled: false, + gateRequirements: [ + "Certified status feed + scheme compliance", + "Operational liability / indemnity agreement", + "Circuit-breakers + honest degrade-to-non-live", + ], + licensingNote: "Certified settlement feed required.", + complianceNote: "Highest liability surface — ops + scheme compliance gate.", + securityNote: "Live feed is continuous egress; requires monitoring + failover.", + maturity: "P3", + }, + { + id: "certified-cbpr", + track: "CERTIFIED_CBPR", + trackLabel: "Certified CBPR+ validation", + displayName: "Certified CBPR+ / MyStandards validation", + vendorExamples: ["SWIFT MyStandards", "Certified CBPR+ validator"], + description: "Replace local shape checks with a certified CBPR+ / MyStandards validator.", + liveIntegration: false, + enabled: false, + gateRequirements: [ + "Actual certification obtained", + "Only then may the 'not certified' caveat be dropped", + "License for certified rule content", + ], + licensingNote: "Certified rule content is licensed.", + complianceNote: "'Certified' wording is forbidden until certification exists.", + securityNote: "Validator integration must not exfiltrate message content.", + maturity: "P3", + }, +]; + +export class ConnectorDisabledError extends Error { + readonly connectorId: string; + + constructor(connectorId: string) { + super( + `Connector "${connectorId}" is disabled (live_integration=false, enabled=false). ` + + "It cannot be invoked until licensing, compliance and security gates are signed off.", + ); + this.name = "ConnectorDisabledError"; + this.connectorId = connectorId; + } +} + +export function getConnector(id: string): ConnectorProfile | null { + return CONNECTORS.find((connector) => connector.id === id) ?? null; +} + +/** + * The only "call" entry point — and it always throws. There is deliberately no + * branch that performs a real provider request. + */ +export function invokeConnector(id: string): never { + throw new ConnectorDisabledError(id); +} + +export interface ConnectorGateMatrix { + total: number; + liveEnabled: number; + disabled: number; + tracks: ConnectorTrack[]; +} + +export function gateMatrix(): ConnectorGateMatrix { + const liveEnabled = CONNECTORS.filter( + (connector) => connector.liveIntegration || connector.enabled, + ).length; + return { + total: CONNECTORS.length, + liveEnabled, + disabled: CONNECTORS.length - liveEnabled, + tracks: CONNECTORS.map((connector) => connector.track), + }; +} diff --git a/src/lib/workbench/index.ts b/src/lib/workbench/index.ts new file mode 100644 index 0000000..49b5c05 --- /dev/null +++ b/src/lib/workbench/index.ts @@ -0,0 +1,8 @@ +export * from "./types"; +export * from "./taxonomy"; +export * from "./surfaces"; +export * from "./scenarios"; +export * from "./analytics"; +export * from "./review"; +export * from "./vault"; +export * from "./connectors"; diff --git a/src/lib/workbench/review.test.ts b/src/lib/workbench/review.test.ts new file mode 100644 index 0000000..73e011d --- /dev/null +++ b/src/lib/workbench/review.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { countByStatus, deriveReviewItems, setReviewStatus } from "./review"; +import type { ModuleResult, Provenance } from "./types"; + +const provenance: Provenance = { + source: "synthetic", + method: "synthetic-fixture", + checked: [], + notChecked: [], + live: false, +}; + +const results: ModuleResult[] = [ + { + moduleId: "m", + documentId: "d", + resultStatus: "warning", + findings: [ + { id: "w", severity: "warning", message: "w" }, + { id: "c", severity: "critical", message: "c" }, + { id: "p", severity: "pass", message: "p" }, + ], + provenance, + limitations: [], + }, +]; + +describe("review queue", () => { + it("derives only warning/critical items, criticals first", () => { + const items = deriveReviewItems(results); + expect(items).toHaveLength(2); + expect(items[0]?.severity).toBe("critical"); + expect(items.every((item) => item.status === "open")).toBe(true); + }); + + it("updates status immutably and counts by status", () => { + const items = deriveReviewItems(results); + const first = items[0]; + if (!first) throw new Error("expected at least one review item"); + const next = setReviewStatus(items, first.id, "resolved"); + expect(items[0]?.status).toBe("open"); + expect(countByStatus(next).resolved).toBe(1); + expect(countByStatus(next).open).toBe(1); + }); +}); diff --git a/src/lib/workbench/review.ts b/src/lib/workbench/review.ts new file mode 100644 index 0000000..8f7a1cd --- /dev/null +++ b/src/lib/workbench/review.ts @@ -0,0 +1,55 @@ +import { SEVERITY_RANK } from "./taxonomy"; +import type { ModuleResult, Severity } from "./types"; + +export type ReviewStatus = "open" | "acknowledged" | "resolved"; + +export interface ReviewItem { + id: string; + moduleId: string; + documentId: string; + severity: Severity; + message: string; + field?: string | undefined; + status: ReviewStatus; +} + +const NEEDS_REVIEW: ReadonlySet = new Set(["warning", "critical"]); + +/** + * Derive a session-scoped review queue from the warning/critical findings of + * this session's results. Pure: returns a fresh array, mutates nothing. + */ +export function deriveReviewItems(results: readonly ModuleResult[]): ReviewItem[] { + const items: ReviewItem[] = []; + for (const result of results) { + for (const finding of result.findings) { + if (!NEEDS_REVIEW.has(finding.severity)) continue; + items.push({ + id: `${result.documentId}:${finding.id}`, + moduleId: result.moduleId, + documentId: result.documentId, + severity: finding.severity, + message: finding.message, + field: finding.field, + status: "open", + }); + } + } + return items.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]); +} + +export function setReviewStatus( + items: readonly ReviewItem[], + id: string, + status: ReviewStatus, +): ReviewItem[] { + return items.map((item) => (item.id === id ? { ...item, status } : item)); +} + +export function countByStatus(items: readonly ReviewItem[]): Record { + const counts: Record = { open: 0, acknowledged: 0, resolved: 0 }; + for (const item of items) { + counts[item.status] += 1; + } + return counts; +} diff --git a/src/lib/workbench/scenarios.test.ts b/src/lib/workbench/scenarios.test.ts new file mode 100644 index 0000000..556f748 --- /dev/null +++ b/src/lib/workbench/scenarios.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { SCENARIOS, getScenario, resultForScenario, scenariosForModule } from "./scenarios"; + +describe("scenarios", () => { + it("ships clearly-synthetic bundled scenarios", () => { + expect(SCENARIOS.length).toBeGreaterThan(0); + for (const scenario of SCENARIOS) { + expect(scenario.previewXml).toContain("Document"); + expect(scenario.previewXml).toMatch(/SYNTH|TEST/); + } + }); + + it("builds a non-live synthetic result for a scenario", () => { + const first = SCENARIOS[0]; + if (!first) throw new Error("expected at least one scenario"); + const result = resultForScenario(first, "doc-1"); + expect(result.documentId).toBe("doc-1"); + expect(result.provenance.live).toBe(false); + expect(result.provenance.method).toBe("synthetic-fixture"); + expect(result.findings.length).toBeGreaterThan(0); + expect(result.limitations.join(" ")).toMatch(/synthetic/i); + }); + + it("looks scenarios up by id and by module", () => { + const first = SCENARIOS[0]; + if (!first) throw new Error("expected at least one scenario"); + expect(getScenario(first.id)?.id).toBe(first.id); + expect(getScenario("nope")).toBeUndefined(); + expect(scenariosForModule("storyteller").length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/workbench/scenarios.ts b/src/lib/workbench/scenarios.ts new file mode 100644 index 0000000..86718b1 --- /dev/null +++ b/src/lib/workbench/scenarios.ts @@ -0,0 +1,204 @@ +// Bundled, deliberately-synthetic example scenarios for the scenario switcher. +// +// Every snippet below is fabricated for demonstration: BICs follow the +// `TEST..` pattern and account numbers are pre-masked. Nothing here is real +// payment data, and the switcher performs no network calls — the content is +// shipped in-code so the simulator works fully offline. + +import type { Finding, ModuleResult, Severity } from "./types"; + +export type ScenarioArchetype = "clean" | "return" | "status" | "non-latin" | "ack-nack"; + +export interface Scenario { + id: string; + label: string; + description: string; + /** Suite module this scenario is most relevant to. */ + moduleId: string; + family: string; + archetype: ScenarioArchetype; + /** Tiny synthetic ISO 20022 fragment — safe to display and hand off. */ + previewXml: string; +} + +const PACS008_CLEAN = ` + + + SYNTH-MSG-00012026-01-02T09:15:001 + + SYNTH-E2E-000100000000-0000-4000-8000-000000000001 + 1000.00 + Synthetic Debtor AG + TESTDEFFXXX + TESTFRPPXXX + Synthetic Creditor SA + FR7630006000010000000000*** + + +`; + +const PACS002_STATUS = ` + + + SYNTH-STS-00012026-01-02T09:16:00 + + SYNTH-E2E-0001 + 00000000-0000-4000-8000-000000000001 + ACSP + + +`; + +const PACS004_RETURN = ` + + + SYNTH-RTN-00012026-01-03T11:00:001 + + SYNTH-E2E-0001 + 00000000-0000-4000-8000-000000000001 + 1000.00 + AC04 + + +`; + +const CAMT053_STATEMENT = ` + + + SYNTH-STMT-00012026-01-04T06:00:00 + + SYNTH-STMT-0001 + DE89370400440000000000*** + 1000.00CRDTBOOK + + +`; + +export const SCENARIOS: Scenario[] = [ + { + id: "clean-credit-transfer", + label: "Clean credit transfer (pacs.008)", + description: + "A well-formed single credit transfer. Good baseline for Storyteller and CBPR+ shape checks.", + moduleId: "storyteller", + family: "pacs.008", + archetype: "clean", + previewXml: PACS008_CLEAN, + }, + { + id: "status-report", + label: "Status report (pacs.002 · ACSP)", + description: + "An accepted-settlement status that correlates to the clean transfer by UETR and end-to-end id.", + moduleId: "insights", + family: "pacs.002", + archetype: "status", + previewXml: PACS002_STATUS, + }, + { + id: "payment-return", + label: "Payment return (pacs.004 · AC04)", + description: + "A return for the same UETR with reason AC04 — useful for lifecycle threading in Insights.", + moduleId: "insights", + family: "pacs.004", + archetype: "return", + previewXml: PACS004_RETURN, + }, + { + id: "bank-statement", + label: "Bank statement entry (camt.053)", + description: "A single booked entry. Exercises Storyteller's statement narrative path.", + moduleId: "storyteller", + family: "camt.053", + archetype: "clean", + previewXml: CAMT053_STATEMENT, + }, +]; + +export function getScenario(id: string): Scenario | undefined { + return SCENARIOS.find((scenario) => scenario.id === id); +} + +export function scenariosForModule(moduleId: string): Scenario[] { + return SCENARIOS.filter((scenario) => scenario.moduleId === moduleId); +} + +/** + * Build a synthetic in-memory ModuleResult for a scenario so the workbench, + * analytics and review surfaces have honest session data to display. The + * provenance is explicitly synthetic and non-live. + */ +export function resultForScenario(scenario: Scenario, documentId: string): ModuleResult { + const findings: Finding[] = []; + let status: Severity = "pass"; + + switch (scenario.archetype) { + case "clean": + findings.push({ + id: "shape-ok", + severity: "pass", + message: "Well-formed message; AppHdr/Document shape recognised.", + moduleId: scenario.moduleId, + }); + status = "pass"; + break; + case "status": + findings.push({ + id: "status-code", + severity: "info", + field: "TxSts", + message: "Status report present (e.g. ACSP) — correlate by UETR / end-to-end id.", + moduleId: scenario.moduleId, + }); + status = "info"; + break; + case "return": + findings.push({ + id: "return-reason", + severity: "warning", + field: "RtrRsnInf/Rsn/Cd", + message: "Return present with a reason code (e.g. AC04) — needs lifecycle review.", + moduleId: scenario.moduleId, + }); + status = "warning"; + break; + case "non-latin": + findings.push({ + id: "charset", + severity: "warning", + message: "Non-Latin characters present — check downstream character-set handling.", + moduleId: scenario.moduleId, + }); + status = "warning"; + break; + case "ack-nack": + findings.push({ + id: "ack-nack", + severity: "info", + message: "ACK/NACK fragment — group with related messages in Insights.", + moduleId: scenario.moduleId, + }); + status = "info"; + break; + } + + return { + moduleId: scenario.moduleId, + documentId, + messageFamily: scenario.family, + resultStatus: status, + findings, + provenance: { + source: "Bundled synthetic scenario fixture", + method: "synthetic-fixture", + checked: ["XML well-formedness (shape)", "Message family detection"], + notChecked: ["Live BIC directory", "VOP / name match", "Reachability", "Settlement status"], + live: false, + }, + limitations: [ + "Synthetic fixture — not a real message", + "Shape checks only, not certified validation", + ], + }; +} diff --git a/src/lib/workbench/session/SessionContext.ts b/src/lib/workbench/session/SessionContext.ts new file mode 100644 index 0000000..50416ac --- /dev/null +++ b/src/lib/workbench/session/SessionContext.ts @@ -0,0 +1,35 @@ +import { createContext } from "react"; +import type { ModuleResult } from "../types"; + +export interface SessionDocument { + id: string; + label: string; + moduleId?: string | undefined; + family?: string | undefined; + content: string; + loadedAt: string; +} + +export interface HandoffPayload { + targetModuleId: string; + label: string; + content: string; +} + +export interface SessionContextValue { + documents: SessionDocument[]; + results: ModuleResult[]; + handoff: HandoffPayload | null; + addDocument(doc: Omit): string; + removeDocument(id: string): void; + addResult(result: ModuleResult): void; + stageHandoff(payload: HandoffPayload): void; + clearHandoff(): void; + clearSession(): void; +} + +/** + * In-memory only. The provider holds React state; there is no persistence, + * so everything here is cleared on refresh. Never wire this to storage. + */ +export const SessionContext = createContext(null); diff --git a/src/lib/workbench/session/SessionProvider.tsx b/src/lib/workbench/session/SessionProvider.tsx new file mode 100644 index 0000000..aded0d2 --- /dev/null +++ b/src/lib/workbench/session/SessionProvider.tsx @@ -0,0 +1,77 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import type { ModuleResult } from "../types"; +import { + SessionContext, + type HandoffPayload, + type SessionContextValue, + type SessionDocument, +} from "./SessionContext"; + +function newId(): string { + const candidate = globalThis.crypto as Crypto | undefined; + if (candidate && "randomUUID" in candidate) { + return candidate.randomUUID(); + } + return `doc-${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`; +} + +export function SessionProvider({ children }: { children: ReactNode }) { + const [documents, setDocuments] = useState([]); + const [results, setResults] = useState([]); + const [handoff, setHandoff] = useState(null); + + const addDocument = useCallback((doc: Omit): string => { + const id = newId(); + setDocuments((prev) => [{ ...doc, id, loadedAt: new Date().toISOString() }, ...prev]); + return id; + }, []); + + const removeDocument = useCallback((id: string) => { + setDocuments((prev) => prev.filter((doc) => doc.id !== id)); + }, []); + + const addResult = useCallback((result: ModuleResult) => { + setResults((prev) => [result, ...prev]); + }, []); + + const stageHandoff = useCallback((payload: HandoffPayload) => { + setHandoff(payload); + }, []); + + const clearHandoff = useCallback(() => { + setHandoff(null); + }, []); + + const clearSession = useCallback(() => { + setDocuments([]); + setResults([]); + setHandoff(null); + }, []); + + const value = useMemo( + () => ({ + documents, + results, + handoff, + addDocument, + removeDocument, + addResult, + stageHandoff, + clearHandoff, + clearSession, + }), + [ + documents, + results, + handoff, + addDocument, + removeDocument, + addResult, + stageHandoff, + clearHandoff, + clearSession, + ], + ); + + return {children}; +} diff --git a/src/lib/workbench/session/index.ts b/src/lib/workbench/session/index.ts new file mode 100644 index 0000000..022a2f6 --- /dev/null +++ b/src/lib/workbench/session/index.ts @@ -0,0 +1,4 @@ +export { SessionProvider } from "./SessionProvider"; +export { useSession, useOptionalSession } from "./useSession"; +export { SessionContext } from "./SessionContext"; +export type { SessionContextValue, SessionDocument, HandoffPayload } from "./SessionContext"; diff --git a/src/lib/workbench/session/useSession.ts b/src/lib/workbench/session/useSession.ts new file mode 100644 index 0000000..69d9a7d --- /dev/null +++ b/src/lib/workbench/session/useSession.ts @@ -0,0 +1,19 @@ +import { useContext } from "react"; +import { SessionContext, type SessionContextValue } from "./SessionContext"; + +export function useSession(): SessionContextValue { + const ctx = useContext(SessionContext); + if (!ctx) { + throw new Error("useSession must be used within a SessionProvider."); + } + return ctx; +} + +/** + * Non-throwing variant: returns null when there is no provider. Useful for + * optional integrations (e.g. handoff consumption) inside module pages that + * are also rendered standalone in unit tests without a SessionProvider. + */ +export function useOptionalSession(): SessionContextValue | null { + return useContext(SessionContext); +} diff --git a/src/lib/workbench/surfaces.ts b/src/lib/workbench/surfaces.ts new file mode 100644 index 0000000..fc0c414 --- /dev/null +++ b/src/lib/workbench/surfaces.ts @@ -0,0 +1,240 @@ +import { + Activity, + BarChart3, + BookOpen, + Building2, + Eraser, + FileCheck2, + FileText, + FlaskConical, + GitBranch, + Hash, + LayoutDashboard, + ListChecks, + Lock, + PlugZap, + ServerCog, + type LucideIcon, +} from "lucide-react"; +import type { CapabilityState, MaturityTier } from "./types"; + +export type SurfaceGroup = "module" | "workbench" | "platform"; + +export interface SuiteSurface { + id: string; + route: string; + name: string; + /** Short label for nav / footer chips. */ + short: string; + summary: string; + icon: LucideIcon; + group: SurfaceGroup; + maturity: MaturityTier; + capability: CapabilityState; + caveats?: string[]; +} + +/** + * Single source of truth for the suite's discovery surface. + * + * The SSI Control Tower (`/ssi`) is intentionally NOT registered here: it stays + * an unlinked boundary pointer, never promoted in nav or on the home launcher. + */ +export const SURFACES: SuiteSurface[] = [ + { + id: "scrubber", + route: "/scrubber", + name: "Scrubber", + short: "Scrubber", + summary: "Strip personally identifying fields from ISO 20022 payment XML before sharing.", + icon: Eraser, + group: "module", + maturity: "P0", + capability: "available", + }, + { + id: "storyteller", + route: "/storyteller", + name: "Storyteller", + short: "Storyteller", + summary: + "Turn pacs.* and camt.* messages into a plain-language narrative and field projection.", + icon: FileText, + group: "module", + maturity: "P0", + capability: "available", + }, + { + id: "iban", + route: "/iban", + name: "IBAN Workbench", + short: "IBAN", + summary: + "Validate, build, catalogue and trace IBAN provenance from the bundled registry snapshot.", + icon: Hash, + group: "module", + maturity: "P0", + capability: "available", + caveats: ["No live BIC lookup", "No VOP / account-name check", "No reachability check"], + }, + { + id: "bic", + route: "/bic", + name: "BIC Validator", + short: "BIC*", + summary: "ISO 9362 structural checks plus a small bundled snapshot. Demonstration data only.", + icon: Building2, + group: "module", + maturity: "P0", + capability: "demo", + caveats: ["Snapshot is not accurate/current", "Not a live directory lookup"], + }, + { + id: "cbpr", + route: "/cbpr", + name: "CBPR+ Readiness Checker", + short: "CBPR+", + summary: + "Inspect AppHdr, Document namespace and bundled CBPR+ schema-profile coverage locally.", + icon: FileCheck2, + group: "module", + maturity: "P0", + capability: "available", + caveats: ["Shape checks, not certified validation", "Not a MyStandards substitute"], + }, + { + id: "insights", + route: "/insights", + name: "Payment Insights Lite", + short: "Insights", + summary: "Group ACK/NACK, pacs.* and camt.* files you provide into local lifecycle threads.", + icon: GitBranch, + group: "module", + maturity: "P0", + capability: "available", + caveats: ["Not live payment tracking", "Not settlement monitoring"], + }, + { + id: "workbench", + route: "/workbench", + name: "Workbench Command Center", + short: "Workbench", + summary: + "An in-memory command center over this browser session: loaded documents, findings, suggested next actions and cross-module handoff.", + icon: LayoutDashboard, + group: "workbench", + maturity: "P1", + capability: "local", + caveats: ["Session-only — cleared on refresh", "Nothing is stored or uploaded"], + }, + { + id: "scenarios", + route: "/scenarios", + name: "Scenario Switcher", + short: "Scenarios", + summary: + "Load bundled, clearly-synthetic ISO 20022 example scenarios into the session and hand them to a module.", + icon: FlaskConical, + group: "workbench", + maturity: "P0", + capability: "available", + caveats: ["Synthetic fixtures only", "Fake BICs, masked accounts"], + }, + { + id: "review", + route: "/review", + name: "Review Queue", + short: "Review", + summary: "Triage low-confidence and failed findings from this session as a local review queue.", + icon: ListChecks, + group: "workbench", + maturity: "P1", + capability: "local", + caveats: ["Session-only triage", "No tickets, no backend case management"], + }, + { + id: "analytics", + route: "/analytics", + name: "Analytics", + short: "Analytics", + summary: "Counts, pass-rates and reason-code trends across the analyses run in this session.", + icon: BarChart3, + group: "workbench", + maturity: "P1", + capability: "local", + caveats: ["Current-session / synthetic data only", "No telemetry, no remote analytics"], + }, + { + id: "vault", + route: "/vault", + name: "Local Vault", + short: "Vault", + summary: + "User-triggered, passphrase-encrypted export/import of session data via WebCrypto and a downloaded file.", + icon: Lock, + group: "workbench", + maturity: "P1", + capability: "local", + caveats: ["No cloud vault", "No browser storage — file download/import only"], + }, + { + id: "health", + route: "/health", + name: "Health & Scope", + short: "Health", + summary: + "Static build/version/scope and privacy-posture surface, plus an honest map of what is and isn't live.", + icon: Activity, + group: "platform", + maturity: "P0", + capability: "available", + }, + { + id: "docs", + route: "/docs", + name: "Docs, Methodology & Support", + short: "Docs", + summary: "Module guides, methodology, the privacy model, limitations and the maturity roadmap.", + icon: BookOpen, + group: "platform", + maturity: "P0", + capability: "available", + }, + { + id: "connectors", + route: "/connectors", + name: "Connector Readiness", + short: "Connectors", + summary: + "P3 live-data connector framework (BIC / VOP / reachability / MQ / directory / payment-monitor / certified CBPR+) — every track disabled behind explicit gates.", + icon: PlugZap, + group: "platform", + maturity: "P3", + capability: "gated", + caveats: ["live_integration = false for every connector", "No real provider calls"], + }, + { + id: "pilot", + route: "/pilot", + name: "Private-Pilot Scaffold", + short: "Pilot", + summary: + "P2 hosted-pilot architecture: an offline FastAPI scaffold with stub auth, immutable audit and four-eyes approval — a prototype, never production.", + icon: ServerCog, + group: "platform", + maturity: "P2", + capability: "prototype", + caveats: [ + "Separate offline app, not wired to this static suite", + "Stub auth — not production auth", + ], + }, +]; + +export function surfacesByGroup(group: SurfaceGroup): SuiteSurface[] { + return SURFACES.filter((surface) => surface.group === group); +} + +export function getSurface(id: string): SuiteSurface | undefined { + return SURFACES.find((surface) => surface.id === id); +} diff --git a/src/lib/workbench/taxonomy.test.ts b/src/lib/workbench/taxonomy.test.ts new file mode 100644 index 0000000..e54df82 --- /dev/null +++ b/src/lib/workbench/taxonomy.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + CAPABILITY_LABEL, + MATURITY_LABEL, + SEVERITY_LABEL, + highestSeverity, + tallyFindings, +} from "./taxonomy"; +import type { Finding } from "./types"; + +const findings: Finding[] = [ + { id: "a", severity: "info", message: "i" }, + { id: "b", severity: "critical", message: "c" }, + { id: "c", severity: "warning", message: "w" }, +]; + +describe("taxonomy", () => { + it("highestSeverity picks the worst severity", () => { + expect(highestSeverity(findings)).toBe("critical"); + expect(highestSeverity([])).toBe("pass"); + }); + + it("tallyFindings counts by severity", () => { + expect(tallyFindings(findings)).toEqual({ pass: 0, info: 1, warning: 1, critical: 1 }); + }); + + it("exposes honest labels", () => { + expect(SEVERITY_LABEL.critical).toBe("Critical"); + expect(CAPABILITY_LABEL.gated).toMatch(/disabled/i); + expect(MATURITY_LABEL.P3).toMatch(/gated/i); + }); +}); diff --git a/src/lib/workbench/taxonomy.ts b/src/lib/workbench/taxonomy.ts new file mode 100644 index 0000000..c437bdc --- /dev/null +++ b/src/lib/workbench/taxonomy.ts @@ -0,0 +1,65 @@ +import type { CapabilityState, Finding, MaturityTier, Severity } from "./types"; + +export const SEVERITY_RANK: Record = { + pass: 0, + info: 1, + warning: 2, + critical: 3, +}; + +export const SEVERITY_LABEL: Record = { + pass: "Pass", + info: "Info", + warning: "Needs review", + critical: "Critical", +}; + +export const CAPABILITY_LABEL: Record = { + available: "Available", + demo: "Demo", + local: "Local prototype", + prototype: "Pilot scaffold", + gated: "Gated · disabled", + planned: "Planned", +}; + +export const CAPABILITY_DESCRIPTION: Record = { + available: "Runs now, fully in your browser.", + demo: "Runs now with intentionally limited or snapshot data.", + local: "In-memory operational UX for this browser session only.", + prototype: "Offline, synthetic private-pilot scaffold — not production, not hosted here.", + gated: "Designed but switched off behind explicit licensing, compliance and security gates.", + planned: "Designed, not yet implemented.", +}; + +export const MATURITY_LABEL: Record = { + P0: "P0 · Public static", + P1: "P1 · Local operational", + P2: "P2 · Private-pilot scaffold", + P3: "P3 · Enterprise / live (gated)", +}; + +export const MATURITY_DESCRIPTION: Record = { + P0: "Browser-only suite. No backend, no storage, no telemetry.", + P1: "In-memory session UX layered on the static suite. Still no persistence by default.", + P2: "Separate, offline FastAPI scaffold with stub auth — a prototype, never production.", + P3: "Disabled live-data connector framework. Every integration is off until gates are signed.", +}; + +export function highestSeverity(findings: readonly Finding[]): Severity { + let worst: Severity = "pass"; + for (const finding of findings) { + if (SEVERITY_RANK[finding.severity] > SEVERITY_RANK[worst]) { + worst = finding.severity; + } + } + return worst; +} + +export function tallyFindings(findings: readonly Finding[]): Record { + const tally: Record = { pass: 0, info: 0, warning: 0, critical: 0 }; + for (const finding of findings) { + tally[finding.severity] += 1; + } + return tally; +} diff --git a/src/lib/workbench/types.ts b/src/lib/workbench/types.ts new file mode 100644 index 0000000..7858a04 --- /dev/null +++ b/src/lib/workbench/types.ts @@ -0,0 +1,51 @@ +// Shared "payment intelligence workbench" taxonomy. +// +// These types give every module/surface a common vocabulary for findings, +// provenance, and capability maturity. They are pure data shapes with no +// persistence and no network — they describe in-memory analysis only. + +export type MaturityTier = "P0" | "P1" | "P2" | "P3"; + +export type CapabilityState = + | "available" // works now, fully browser-only + | "demo" // works now, intentionally limited / snapshot data + | "local" // P1 local/in-memory operational UX + | "prototype" // P2 hosted private-pilot scaffold (offline, synthetic) + | "gated" // P3 enterprise/live, implemented as a disabled gated surface + | "planned"; // designed, not yet implemented + +export type Severity = "pass" | "info" | "warning" | "critical"; + +/** + * Where a result came from and — just as importantly — what it did NOT check. + * `live` is hard-typed `false`: the browser suite never reaches a live source. + */ +export interface Provenance { + source: string; + method: "syntax" | "registry-snapshot" | "heuristic" | "narrative" | "synthetic-fixture"; + checked: string[]; + notChecked: string[]; + freshness?: string | undefined; + live: false; +} + +export interface Finding { + id: string; + severity: Severity; + message: string; + field?: string | undefined; + moduleId?: string | undefined; +} + +/** + * A common conceptual output shape for any module, held in memory only. + */ +export interface ModuleResult { + moduleId: string; + documentId: string; + messageFamily?: string | undefined; + resultStatus: Severity; + findings: Finding[]; + provenance: Provenance; + limitations: string[]; +} diff --git a/src/lib/workbench/vault.test.ts b/src/lib/workbench/vault.test.ts new file mode 100644 index 0000000..4953b57 --- /dev/null +++ b/src/lib/workbench/vault.test.ts @@ -0,0 +1,36 @@ +import { webcrypto } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { decryptFromVault, encryptToVault, isVaultBundle, type VaultBundle } from "./vault"; + +const cryptoImpl = webcrypto as unknown as Crypto; + +describe("vault (WebCrypto, user-triggered only)", () => { + it("round-trips an encrypted payload", async () => { + const payload = { documents: [{ id: "1", label: "x" }], note: "hi" }; + const bundle = await encryptToVault(payload, "correct horse battery", { crypto: cryptoImpl }); + expect(isVaultBundle(bundle)).toBe(true); + expect(bundle.cipher).toBe("AES-GCM"); + expect(bundle.kdf).toBe("PBKDF2-SHA256"); + + const decoded = await decryptFromVault(bundle, "correct horse battery", { crypto: cryptoImpl }); + expect(decoded).toEqual(payload); + }); + + it("fails to decrypt with the wrong passphrase", async () => { + const bundle = await encryptToVault({ a: 1 }, "right", { crypto: cryptoImpl }); + await expect(decryptFromVault(bundle, "wrong", { crypto: cryptoImpl })).rejects.toThrow( + /Decryption failed/i, + ); + }); + + it("requires a passphrase to encrypt", async () => { + await expect(encryptToVault({}, "", { crypto: cryptoImpl })).rejects.toThrow(/passphrase/i); + }); + + it("rejects objects that are not vault bundles", async () => { + expect(isVaultBundle({ format: "nope" })).toBe(false); + await expect( + decryptFromVault({ format: "nope" } as unknown as VaultBundle, "x", { crypto: cryptoImpl }), + ).rejects.toThrow(/vault bundle/i); + }); +}); diff --git a/src/lib/workbench/vault.ts b/src/lib/workbench/vault.ts new file mode 100644 index 0000000..6ee74c4 --- /dev/null +++ b/src/lib/workbench/vault.ts @@ -0,0 +1,139 @@ +// Local Vault — passphrase-encrypted, user-triggered export/import. +// +// This module performs encryption only. It NEVER writes to localStorage, +// sessionStorage, indexedDB or cookies, and never makes a network call. The +// caller is responsible for turning a VaultBundle into a downloaded file and +// for reading an imported file back — both of which are explicit user actions. +// +// `crypto` is injectable so the same code path can be unit-tested with Node's +// WebCrypto while running on `globalThis.crypto` in the browser. + +export interface VaultBundle { + format: "pim-vault"; + version: 1; + kdf: "PBKDF2-SHA256"; + iterations: number; + cipher: "AES-GCM"; + salt: string; + iv: string; + ciphertext: string; + createdAt: string; + note: string; +} + +export interface VaultCryptoOptions { + crypto?: Crypto; +} + +const DEFAULT_ITERATIONS = 150_000; +const DEFAULT_NOTE = + "Encrypted locally with WebCrypto (AES-GCM + PBKDF2). No server and no browser storage — this bundle only exists as a file you chose to download."; + +function resolveCrypto(provided?: Crypto): Crypto { + const candidate = provided ?? (globalThis.crypto as Crypto | undefined); + if (!candidate || !candidate.subtle) { + throw new Error("WebCrypto SubtleCrypto API is unavailable in this environment."); + } + return candidate; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +async function deriveKey( + crypto: Crypto, + passphrase: string, + salt: Uint8Array, + iterations: number, +): Promise { + const baseKey = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(passphrase), + "PBKDF2", + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +export async function encryptToVault( + payload: unknown, + passphrase: string, + options: VaultCryptoOptions & { note?: string } = {}, +): Promise { + if (!passphrase) { + throw new Error("A passphrase is required to encrypt a vault bundle."); + } + const crypto = resolveCrypto(options.crypto); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(crypto, passphrase, salt, DEFAULT_ITERATIONS); + const plaintext = new TextEncoder().encode(JSON.stringify(payload)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext), + ); + + return { + format: "pim-vault", + version: 1, + kdf: "PBKDF2-SHA256", + iterations: DEFAULT_ITERATIONS, + cipher: "AES-GCM", + salt: bytesToBase64(salt), + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(ciphertext), + createdAt: new Date().toISOString(), + note: options.note ?? DEFAULT_NOTE, + }; +} + +export async function decryptFromVault( + bundle: VaultBundle, + passphrase: string, + options: VaultCryptoOptions = {}, +): Promise { + if (!bundle || bundle.format !== "pim-vault") { + throw new Error("Not a Payment Intelligence vault bundle."); + } + const crypto = resolveCrypto(options.crypto); + const key = await deriveKey(crypto, passphrase, base64ToBytes(bundle.salt), bundle.iterations); + let plaintext: ArrayBuffer; + try { + plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(bundle.iv) }, + key, + base64ToBytes(bundle.ciphertext), + ); + } catch { + throw new Error("Decryption failed — wrong passphrase or corrupted bundle."); + } + return JSON.parse(new TextDecoder().decode(plaintext)) as T; +} + +export function isVaultBundle(value: unknown): value is VaultBundle { + return ( + typeof value === "object" && + value !== null && + (value as { format?: unknown }).format === "pim-vault" + ); +} diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx new file mode 100644 index 0000000..2ce7810 --- /dev/null +++ b/src/pages/AnalyticsPage.tsx @@ -0,0 +1,104 @@ +import { Link } from "react-router-dom"; +import { ModuleLayout } from "@/components/layout/ModuleLayout"; +import { CaveatPanel } from "@/components/workbench/CaveatPanel"; +import { computeAnalytics, severityList } from "@/lib/workbench/analytics"; +import { SEVERITY_LABEL } from "@/lib/workbench/taxonomy"; +import { useSession } from "@/lib/workbench/session"; + +export function AnalyticsPage() { + const { results, documents } = useSession(); + const analytics = computeAnalytics(results, documents.length); + const maxSeverity = Math.max( + 1, + ...severityList().map((severity) => analytics.bySeverity[severity]), + ); + + return ( + + Back to workbench + + } + > + {analytics.totalResults === 0 ? ( +
+

No analyses in this session yet.

+ + Load a scenario + +
+ ) : ( +
+
+

Findings by severity

+
    + {severityList().map((severity) => { + const value = analytics.bySeverity[severity]; + return ( +
  • + + {SEVERITY_LABEL[severity]} + + + + + + {value} + +
  • + ); + })} +
+

+ Pass rate:{" "} + {Math.round(analytics.passRate * 100)}%{" "} + across {analytics.totalResults} analyses. +

+
+ +
+

By module

+
    + {analytics.byModule.map((entry) => ( +
  • + {entry.moduleId} + + {entry.results} analyses · {entry.findings} findings + +
  • + ))} +
+ +

Top reason codes

+
    + {analytics.reasonCodes.slice(0, 6).map((reason) => ( +
  • + {reason.code} + {reason.count} +
  • + ))} +
+
+
+ )} + +
+ +
+
+ ); +} diff --git a/src/pages/ConnectorsPage.test.tsx b/src/pages/ConnectorsPage.test.tsx new file mode 100644 index 0000000..85deaae --- /dev/null +++ b/src/pages/ConnectorsPage.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ConnectorsPage } from "./ConnectorsPage"; + +describe("ConnectorsPage", () => { + it("renders all P3 connector tracks as disabled", () => { + render(); + expect( + screen.getByRole("heading", { level: 1, name: /connector readiness/i }), + ).toBeInTheDocument(); + expect(screen.getByText(/live_integration = false/i)).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /attempt invoke/i })).toHaveLength(7); + }); + + it("proves a disabled connector cannot be invoked", async () => { + render(); + const buttons = screen.getAllByRole("button", { name: /attempt invoke/i }); + const first = buttons[0]; + if (!first) throw new Error("expected an invoke button"); + await userEvent.click(first); + expect(await screen.findByText(/Blocked:/i)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/ConnectorsPage.tsx b/src/pages/ConnectorsPage.tsx new file mode 100644 index 0000000..f0261d4 --- /dev/null +++ b/src/pages/ConnectorsPage.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { Ban, ShieldAlert } from "lucide-react"; +import { ModuleLayout } from "@/components/layout/ModuleLayout"; +import { CapabilityBadge } from "@/components/workbench/CapabilityBadge"; +import { GateChecklist } from "@/components/workbench/GateChecklist"; +import { + CONNECTORS, + ConnectorDisabledError, + gateMatrix, + invokeConnector, +} from "@/lib/workbench/connectors"; + +export function ConnectorsPage() { + const matrix = gateMatrix(); + const [attempted, setAttempted] = useState>({}); + + const attemptInvoke = (id: string) => { + try { + invokeConnector(id); + // Unreachable: invokeConnector always throws. + setAttempted((prev) => ({ + ...prev, + [id]: "Unexpectedly returned — this should never happen.", + })); + } catch (err) { + const message = + err instanceof ConnectorDisabledError + ? err.message + : err instanceof Error + ? err.message + : "Blocked."; + setAttempted((prev) => ({ ...prev, [id]: message })); + } + }; + + return ( + +
+
+ +
+ {CONNECTORS.map((connector) => ( +
+
+
+

{connector.trackLabel}

+

{connector.displayName}

+
+ +
+

{connector.description}

+

+ Examples: {connector.vendorExamples.join(", ")} +

+ +
+

+ Gate requirements (unmet) +

+
+ +
+
+ +
+ + {attempted[connector.id] ? ( +

+ Blocked: {attempted[connector.id]} +

+ ) : null} +
+
+ ))} +
+ +

+ Mirrored server-side by the offline scaffold at{" "} + apps/payment-intelligence-pilot, whose registry enforces + the same invariant and whose tests prove disabled connectors cannot be called. +

+
+ ); +} diff --git a/src/pages/DocsPage.tsx b/src/pages/DocsPage.tsx new file mode 100644 index 0000000..8bc1e6a --- /dev/null +++ b/src/pages/DocsPage.tsx @@ -0,0 +1,151 @@ +import { Link } from "react-router-dom"; +import { ModuleLayout } from "@/components/layout/ModuleLayout"; +import { MaturityLegend } from "@/components/workbench/MaturityLegend"; +import { CapabilityBadge } from "@/components/workbench/CapabilityBadge"; +import { CaveatPanel } from "@/components/workbench/CaveatPanel"; +import { surfacesByGroup } from "@/lib/workbench/surfaces"; + +export function DocsPage() { + const modules = surfacesByGroup("module"); + const workbench = surfacesByGroup("workbench"); + const platform = surfacesByGroup("platform"); + + return ( + +
+
+
+

Methodology

+
    +
  • + Syntax / shape checks — structural and + checksum validation (MOD-97, ISO 9362, AppHdr/Document namespaces). These are not + certified validation. +
  • +
  • + Registry-snapshot lookups — IBAN/BIC + reference data is bundled and can go stale. Provenance and freshness are surfaced. +
  • +
  • + Narrative & lifecycle grouping — + derived from the message you provide, in memory, with no live tracking. +
  • +
  • + Shared findings taxonomy — every result + carries a severity (pass / info / needs-review / critical) and a provenance block of + what was and was not checked. +
  • +
+
+ +
+

Module guides

+
+ {modules.map((surface) => ( + + + {surface.name} + + {surface.summary} + + + + + ))} +
+
+ +
+

Workbench surfaces

+
+ {workbench.map((surface) => ( + + + {surface.name} + + {surface.summary} + + + + + ))} +
+
+ +
+

Platform & expansion

+
+ {platform.map((surface) => ( + + + {surface.name} + + {surface.summary} + + + + + ))} +
+
+ +
+

Maturity roadmap

+
+ +
+
+ +
+

Support

+
    +
  • + Pick a bundled scenario from the Scenario Switcher to try a module end-to-end. +
  • +
  • + Run the suite locally with pnpm dev; quality gate + is pnpm verify. +
  • +
  • + The hosted private-pilot scaffold lives at{" "} + apps/payment-intelligence-pilot and is run + separately. +
  • +
+
+
+ + +
+
+ ); +} diff --git a/src/pages/HealthPage.tsx b/src/pages/HealthPage.tsx new file mode 100644 index 0000000..5a54571 --- /dev/null +++ b/src/pages/HealthPage.tsx @@ -0,0 +1,143 @@ +import { Activity, CheckCircle2, MinusCircle } from "lucide-react"; +import { ModuleLayout } from "@/components/layout/ModuleLayout"; +import { MaturityLegend } from "@/components/workbench/MaturityLegend"; +import { SUITE_VERSION } from "@/version"; + +interface ScopeRow { + capability: string; + live: boolean; + note: string; +} + +const SCOPE: ScopeRow[] = [ + { + capability: "ISO 20022 shape / readiness checks", + live: false, + note: "Local, in-browser. Not certified validation.", + }, + { + capability: "IBAN validate / build", + live: false, + note: "MOD-97 computed locally from a bundled registry snapshot.", + }, + { + capability: "BIC lookup", + live: false, + note: "Tiny bundled demo snapshot — not a live directory.", + }, + { + capability: "Verification of Payee (VOP)", + live: false, + note: "Not implemented. P3 gated connector, disabled.", + }, + { + capability: "Scheme reachability", + live: false, + note: "Not implemented. P3 gated connector, disabled.", + }, + { + capability: "Payment / settlement monitoring", + live: false, + note: "Not implemented. P3 gated connector, disabled.", + }, + { + capability: "Certified CBPR+ / MyStandards", + live: false, + note: "Shape checks only until certification — caveat stays.", + }, +]; + +const POSTURE: string[] = [ + "Runs entirely in your browser. Nothing is uploaded.", + "No backend API in the public root suite.", + "No localStorage / sessionStorage / indexedDB / cookies.", + "No analytics, telemetry, error reporting or remote logging.", + "Only network calls are same-origin fetches of bundled /samples/**.", +]; + +export function HealthPage() { + return ( + +
+
+

Public suite status

+

+

+
+
+
Version
+
{SUITE_VERSION}
+
+
+
Runtime
+
Browser SPA
+
+
+
Live integrations
+
0
+
+
+
+
+

Privacy posture

+
    + {POSTURE.map((item) => ( +
  • +
  • + ))} +
+
+
+ +
+

What is and is not live

+

+ Every capability below is local or disabled. The suite never presents live or certified + status without a real source. +

+
+ + + + + + + + + + {SCOPE.map((row) => ( + + + + + + ))} + +
CapabilityLive?Notes
{row.capability} + + + {row.note}
+
+
+ +
+

Maturity tiers

+

+ The hosted pilot exposes its own backed /health{" "} + contract (see the Private-Pilot Scaffold). This page reflects the public static suite. +

+ +
+
+ ); +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index ce9e912..d4cea64 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,96 +1,19 @@ import { Link } from "react-router-dom"; -import { - ArrowRight, - Building2, - CheckCircle2, - Eraser, - FileCheck2, - GitBranch, - FileText, - Hash, - Lock, - ShieldCheck, - Sparkles, -} from "lucide-react"; -import { cn } from "@/lib/utils"; - -interface ModuleTile { - to: string; - name: string; - summary: string; - icon: typeof Eraser; - status: "available" | "demo" | "planned"; -} - -const modules: ModuleTile[] = [ - { - to: "/scrubber", - name: "Scrubber", - summary: - "Strip personally identifying fields from ISO 20022 payment XML before sharing with peers or vendors.", - icon: Eraser, - status: "available", - }, - { - to: "/storyteller", - name: "Storyteller", - summary: - "Turn pacs.* and camt.* messages into a plain-language narrative with a structured field projection.", - icon: FileText, - status: "available", - }, - { - to: "/iban", - name: "IBAN Workbench", - summary: - "Validate, build, catalogue, and trace IBAN provenance from the bundled registry snapshot. No live BIC, no VOP.", - icon: Hash, - status: "available", - }, - { - to: "/bic", - name: "BIC Validator*", - summary: - "ISO 9362 structural checks plus a small bundled snapshot lookup. Demonstration only — data is not accurate/current, with no live lookup or reachability check.", - icon: Building2, - status: "demo", - }, - { - to: "/cbpr", - name: "CBPR+ Readiness Checker", - summary: - "Inspect AppHdr, Document namespace, bundled CBPR+ schema-profile coverage, UETR, BIC, and IBAN shape locally. Not a certified validator.", - icon: FileCheck2, - status: "available", - }, - { - to: "/insights", - name: "Payment Insights Lite", - summary: - "Group ACK/NACK, pacs.* and camt.* files you provide into local lifecycle threads. Not live payment tracking.", - icon: GitBranch, - status: "available", - }, - { - to: "#", - name: "Vault", - summary: - "Planned encrypted local export bundle: user-controlled download/import, no cloud vault, no server storage, and no browser persistence by default.", - icon: Lock, - status: "planned", - }, -]; +import { ArrowRight, CheckCircle2, Lock, ShieldCheck, Sparkles } from "lucide-react"; +import { CapabilityBadge } from "@/components/workbench/CapabilityBadge"; +import { MaturityLegend } from "@/components/workbench/MaturityLegend"; +import { surfacesByGroup, type SuiteSurface } from "@/lib/workbench/surfaces"; const howItWorks = [ "Drop in or paste ISO 20022 XML — Storyteller handles selected pacs./camt. narratives, CBPR+ Readiness checks AppHdr/MX structure, and Payment Insights Lite groups local lifecycle files by identifiers.", - "Parsing, scrubbing, narrative generation, readiness checks, and local lifecycle grouping all run inside the page using browser APIs and bundled metadata.", - "Outputs are produced from in-memory data only and stay on your machine until you copy or download them.", + "Load a bundled, synthetic scenario into the in-memory workbench, then hand it off to a module — all without uploading anything.", + "Outputs are produced from in-memory data only and stay on your machine until you copy, download, or encrypt them into a local vault file.", ]; const whatItDoesNotDo = [ "No upload from the root browser runtime. No root API, telemetry, or remote logging.", "No persistence — nothing is written to localStorage, sessionStorage, IndexedDB or cookies.", - "No telemetry, analytics or error reporting beacons.", + "No live BIC/VOP/reachability/settlement calls. Live-data connectors exist only as disabled, gated stubs.", ]; const supportedMessageFamilies = [ @@ -103,18 +26,44 @@ const supportedMessageFamilies = [ "camt.054", ]; -const stats = [ - { - value: String( - modules.filter((module) => module.status === "available" || module.status === "demo").length, - ), - label: "Live browser modules", - }, - { value: String(supportedMessageFamilies.length), label: "ISO 20022 message families" }, - { value: "0", label: "Uploads, storage, telemetry" }, -]; +function SurfaceCard({ surface }: { surface: SuiteSurface }) { + const Icon = surface.icon; + return ( + +
+ + + +
+

{surface.name}

+

{surface.summary}

+ + Open + + + ); +} export function HomePage() { + const moduleSurfaces = surfacesByGroup("module"); + const workbenchSurfaces = surfacesByGroup("workbench"); + const platformSurfaces = surfacesByGroup("platform"); + + const stats = [ + { value: String(moduleSurfaces.length), label: "Browser modules" }, + { value: String(supportedMessageFamilies.length), label: "ISO 20022 message families" }, + { value: "0", label: "Uploads, storage, telemetry" }, + ]; + return (
@@ -128,12 +77,16 @@ export function HomePage() { the reference data underneath payments.

- Practitioner-grade utilities for payment operations and integration teams reviewing ISO - 20022 XML. Scrub identifying data before sharing a sample, or turn a message into a - plain-language narrative — without uploading anything. + A privacy-first workbench for payment operations and integration teams reviewing ISO + 20022 XML. Scrub, explain, validate-shape and group lifecycle fragments — locally — then + grow, behind honest gates, toward a hosted pilot and gated live-data tracks.

- + + Open the workbench +
{stats.map((stat) => ( @@ -189,77 +138,47 @@ export function HomePage() { Choose a review workflow

- Each module is designed as a small, auditable workflow: clear input, transparent - transformation, and copy/download outputs that stay under your control. + Each module is a small, auditable workflow: clear input, transparent transformation, + and copy/download outputs that stay under your control.

+
+ {moduleSurfaces.map((surface) => ( + + ))} +
+ +
+

Local operational layer (P1)

+

+ Work across modules in one session +

+

+ A command center, scenario switcher, review queue, analytics and an encrypted local vault + — all in-memory, all on your device. +

- {modules.map((module) => { - const Icon = module.icon; - const isPlanned = module.status === "planned"; - const isDemo = module.status === "demo"; - const tileClass = cn( - "group relative flex min-h-64 flex-col p-6 transition-all", - "practice-card", - isPlanned - ? "cursor-not-allowed opacity-65" - : "hover:-translate-y-0.5 hover:border-accent/40 hover:shadow-md hover:shadow-slate-200/80", - ); - const badgeLabel = isPlanned ? "Planned" : isDemo ? "Demo" : "Available"; - const badgeClass = isPlanned - ? "border-border bg-muted text-muted-foreground" - : isDemo - ? "border-amber-300/60 bg-amber-100/70 text-amber-900" - : "border-brand/25 bg-brand/10 text-primary"; - const inner = ( - <> -
- - - - {badgeLabel} - -
-

- {module.name} -

-

- {module.summary} -

- {!isPlanned ? ( - - Open module - - ) : ( - - Coming soon - - )} - - ); + {workbenchSurfaces.map((surface) => ( + + ))} +
+
- return isPlanned ? ( -
- {inner} -
- ) : ( - - {inner} - - ); - })} +
+

Platform & expansion (P0–P3)

+

+ Honest gates, not fake production +

+

+ Status and docs ship now. The hosted pilot and live-data connectors are present as gated, + disabled prototypes — never presented as live or certified. +

+
+ {platformSurfaces.map((surface) => ( + + ))}
@@ -311,6 +230,14 @@ export function HomePage() { + +
+

Maturity

+

+ From static suite to gated platform +

+ +
); } diff --git a/src/pages/PilotPage.tsx b/src/pages/PilotPage.tsx new file mode 100644 index 0000000..54c15e9 --- /dev/null +++ b/src/pages/PilotPage.tsx @@ -0,0 +1,118 @@ +import { ServerCog } from "lucide-react"; +import { ModuleLayout } from "@/components/layout/ModuleLayout"; +import { CapabilityBadge } from "@/components/workbench/CapabilityBadge"; +import { CaveatPanel } from "@/components/workbench/CaveatPanel"; + +const ENDPOINTS: { method: string; path: string; note: string }[] = [ + { + method: "GET", + path: "/health", + note: "Honest service health; reports live_integrations_enabled: 0.", + }, + { + method: "GET", + path: "/api/v1/dashboard/operator-summary", + note: "Aggregate counts only — no raw payloads.", + }, + { method: "GET", path: "/api/v1/connectors", note: "Disabled connector gate matrix." }, + { + method: "POST", + path: "/api/v1/connectors/{id}/invoke", + note: "Always 409 — disabled connectors cannot be called.", + }, + { + method: "POST", + path: "/api/v1/approvals/{id}/approve", + note: "Four-eyes; self-approval rejected (403).", + }, +]; + +const GATES: string[] = [ + "Real authentication / authorization replaces the X-User-Email demo stub.", + "Persistence is scrub-before-store with retention + deletion, DPA and residency decisions.", + "Signed pilot NDA / SLA before any external participant.", + "Security review (IAM/SSO, secret vaulting, pen-test) before hosting.", +]; + +export function PilotPage() { + return ( + } + > +
+
+ +
+
+

Pattern class (offline)

+
    +
  • + REST API + auto-generated /docs. +
  • +
  • + Auth: DEMO STUB — trusted{" "} + X-User-Email header → role. Not production auth. +
  • +
  • SQLite persistence with insert-only, trigger-enforced immutable audit.
  • +
  • Four-eyes approval with self-approval blocked.
  • +
  • Aggregate-only dashboards (scrub-before-store; no raw payloads).
  • +
  • Readiness scoring with Green / Amber / Red / Critical bands.
  • +
+
+ +
+

API contract (offline)

+ + + {ENDPOINTS.map((endpoint) => ( + + + + + + ))} + +
+ + {endpoint.method} + + + {endpoint.path} + {endpoint.note}
+
+
+ +
+

Run it locally

+

+ The scaffold is run separately from the public suite. No live link is provided here. +

+
+          {`cd apps/payment-intelligence-pilot
+make test   # contract + gate tests (pytest)
+make run    # uvicorn on port 8100 (local only)`}
+        
+
+ +
+ +
+
+ ); +} diff --git a/src/pages/ReviewQueuePage.tsx b/src/pages/ReviewQueuePage.tsx new file mode 100644 index 0000000..9db9d88 --- /dev/null +++ b/src/pages/ReviewQueuePage.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { ModuleLayout } from "@/components/layout/ModuleLayout"; +import { cn } from "@/lib/utils"; +import { SEVERITY_LABEL } from "@/lib/workbench/taxonomy"; +import { + countByStatus, + deriveReviewItems, + setReviewStatus, + type ReviewItem, + type ReviewStatus, +} from "@/lib/workbench/review"; +import { useSession } from "@/lib/workbench/session"; + +const STATUS_LABEL: Record = { + open: "Open", + acknowledged: "Acknowledged", + resolved: "Resolved", +}; + +export function ReviewQueuePage() { + const { results } = useSession(); + const [items, setItems] = useState(() => deriveReviewItems(results)); + + // Re-derive when the session's results change. Local status edits live only + // in component state — there is no persistence. + useEffect(() => { + setItems(deriveReviewItems(results)); + }, [results]); + + const counts = countByStatus(items); + const update = (id: string, status: ReviewStatus) => + setItems((prev) => setReviewStatus(prev, id, status)); + + return ( + + Back to workbench + + } + > +
+ {(["open", "acknowledged", "resolved"] as ReviewStatus[]).map((status) => ( +
+

{counts[status]}

+

{STATUS_LABEL[status]}

+
+ ))} +
+ +
+ {items.length === 0 ? ( +
+

No items to review in this session.

+

+ Load a scenario with warnings or returns to populate the queue. +

+ + Open scenario switcher + +
+ ) : ( +
    + {items.map((item) => ( +
  • +
    +
    + + {SEVERITY_LABEL[item.severity]} + + {item.moduleId} + {item.field ? ( + + {item.field} + + ) : null} +
    +

    {item.message}

    +
    +
    + + + {item.status !== "open" ? ( + + ) : null} +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/ScenariosPage.tsx b/src/pages/ScenariosPage.tsx new file mode 100644 index 0000000..19872be --- /dev/null +++ b/src/pages/ScenariosPage.tsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowRight, FlaskConical, Send } from "lucide-react"; +import { ModuleLayout } from "@/components/layout/ModuleLayout"; +import { CaveatPanel } from "@/components/workbench/CaveatPanel"; +import { SCENARIOS, resultForScenario, type Scenario } from "@/lib/workbench/scenarios"; +import { useSession } from "@/lib/workbench/session"; +import { cn } from "@/lib/utils"; + +export function ScenariosPage() { + const navigate = useNavigate(); + const { addDocument, addResult, stageHandoff } = useSession(); + const [selectedId, setSelectedId] = useState(SCENARIOS[0]?.id ?? ""); + + const selected = SCENARIOS.find((scenario) => scenario.id === selectedId) ?? null; + + const stage = (scenario: Scenario): string => { + const id = addDocument({ + label: scenario.label, + content: scenario.previewXml, + moduleId: scenario.moduleId, + family: scenario.family, + }); + addResult(resultForScenario(scenario, id)); + return id; + }; + + const loadIntoWorkbench = (scenario: Scenario) => { + stage(scenario); + navigate("/workbench"); + }; + + const handoffToModule = (scenario: Scenario) => { + stage(scenario); + stageHandoff({ + targetModuleId: scenario.moduleId, + label: scenario.label, + content: scenario.previewXml, + }); + navigate(`/${scenario.moduleId}`); + }; + + return ( + +
+
+

Choose a scenario

+
    + {SCENARIOS.map((scenario) => ( +
  • + +
  • + ))} +
+
+ +
+ {selected ? ( + <> +
+
+

{selected.label}

+ {selected.archetype} +
+
+                  {selected.previewXml}
+                
+
+ + +
+
+ + + ) : ( +

No scenario selected.

+ )} +
+
+
+ ); +} diff --git a/src/pages/StorytellerPage.tsx b/src/pages/StorytellerPage.tsx index e79f58a..dc58879 100644 --- a/src/pages/StorytellerPage.tsx +++ b/src/pages/StorytellerPage.tsx @@ -25,6 +25,7 @@ import { type SummaryRow, } from "@/lib/storyteller"; import { cn } from "@/lib/utils"; +import { useOptionalSession } from "@/lib/workbench/session"; interface ErrorState { message: string; @@ -51,6 +52,7 @@ export function StorytellerPage() { const fileInputRef = useRef(null); const inputTextareaRef = useRef(null); const sampleSelectWrapperRef = useRef(null); + const session = useOptionalSession(); const focusSampleSelect = () => { sampleSelectWrapperRef.current?.querySelector("select")?.focus(); @@ -107,6 +109,18 @@ export function StorytellerPage() { } }, []); + // Consume a cross-module handoff staged in the session (if any). Uses the + // optional session hook so the page still renders standalone without a + // SessionProvider (e.g. in unit tests). + useEffect(() => { + const handoff = session?.handoff; + if (handoff?.targetModuleId === "storyteller") { + setInput(handoff.content); + runParse(handoff.content); + session?.clearHandoff(); + } + }, [session, runParse]); + const handleBuild = () => runParse(input); const handleClear = () => { diff --git a/src/pages/VaultPage.test.tsx b/src/pages/VaultPage.test.tsx new file mode 100644 index 0000000..87fc508 --- /dev/null +++ b/src/pages/VaultPage.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SessionProvider } from "@/lib/workbench/session"; +import { VaultPage } from "./VaultPage"; + +describe("VaultPage", () => { + it("renders and keeps export disabled until a passphrase is entered", () => { + render( + + + , + ); + expect(screen.getByRole("heading", { level: 1, name: /local vault/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /encrypt & download/i })).toBeDisabled(); + expect(screen.getByText(/no browser storage/i)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/VaultPage.tsx b/src/pages/VaultPage.tsx new file mode 100644 index 0000000..4d57a9c --- /dev/null +++ b/src/pages/VaultPage.tsx @@ -0,0 +1,181 @@ +import { useRef, useState } from "react"; +import { Download, Lock, Upload } from "lucide-react"; +import { ModuleLayout } from "@/components/layout/ModuleLayout"; +import { CaveatPanel } from "@/components/workbench/CaveatPanel"; +import { decryptFromVault, encryptToVault, isVaultBundle } from "@/lib/workbench/vault"; +import type { ModuleResult } from "@/lib/workbench/types"; +import { useSession, type SessionDocument } from "@/lib/workbench/session"; + +interface VaultPayload { + documents?: SessionDocument[]; + results?: ModuleResult[]; +} + +type StatusTone = "idle" | "ok" | "error"; + +export function VaultPage() { + const { documents, results, addDocument, addResult } = useSession(); + const [passphrase, setPassphrase] = useState(""); + const [status, setStatus] = useState<{ tone: StatusTone; message: string }>({ + tone: "idle", + message: "", + }); + const fileRef = useRef(null); + + const handleExport = async () => { + try { + const bundle = await encryptToVault({ documents, results }, passphrase); + const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `payment-intelligence-vault-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + setStatus({ + tone: "ok", + message: "Encrypted bundle downloaded. Nothing was written to browser storage.", + }); + } catch (err) { + setStatus({ tone: "error", message: err instanceof Error ? err.message : "Export failed." }); + } + }; + + const handleImport = async (file: File) => { + try { + const parsed = JSON.parse(await file.text()) as unknown; + if (!isVaultBundle(parsed)) { + throw new Error("Not a Payment Intelligence vault bundle."); + } + const payload = await decryptFromVault(parsed, passphrase); + const importedDocs = payload.documents ?? []; + for (const doc of importedDocs) { + addDocument({ + label: doc.label, + content: doc.content, + moduleId: doc.moduleId, + family: doc.family, + }); + } + for (const result of payload.results ?? []) { + addResult(result); + } + setStatus({ + tone: "ok", + message: `Imported ${importedDocs.length} document(s) into this session only.`, + }); + } catch (err) { + setStatus({ + tone: "error", + message: err instanceof Error ? err.message : "Import failed.", + }); + } + }; + + return ( + +
+
+ +

+ Used to derive an AES-GCM key with PBKDF2 in your browser. It is never stored or sent + anywhere — if you lose it, the bundle cannot be decrypted. +

+ setPassphrase(event.target.value)} + autoComplete="off" + placeholder="Enter a passphrase…" + className="mt-2 w-full rounded-md border border-border bg-background p-2 text-sm text-foreground" + /> +
+ +
+

+

+

+ Encrypts {documents.length} document(s) and {results.length} analysis result(s) from + this session and downloads a single file. +

+ +
+ +
+

+

+

+ Reads a vault file you choose, decrypts it with the passphrase above, and loads it into + this session only. +

+ { + const file = event.target.files?.[0]; + if (file) void handleImport(file); + event.target.value = ""; + }} + /> + +
+
+ + {status.tone !== "idle" ? ( +

+ {status.message} +

+ ) : null} + +
+ +
+
+ ); +} diff --git a/src/pages/WorkbenchPage.test.tsx b/src/pages/WorkbenchPage.test.tsx new file mode 100644 index 0000000..415b408 --- /dev/null +++ b/src/pages/WorkbenchPage.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { SessionProvider } from "@/lib/workbench/session"; +import { WorkbenchPage } from "./WorkbenchPage"; + +function renderPage() { + return render( + + + + + , + ); +} + +describe("WorkbenchPage", () => { + it("renders the command center and an empty in-memory session", () => { + renderPage(); + expect( + screen.getByRole("heading", { level: 1, name: /workbench command center/i }), + ).toBeInTheDocument(); + expect( + screen.getByText(/runs entirely in your browser\. nothing is uploaded\./i), + ).toBeInTheDocument(); + expect(screen.getByText(/no documents loaded in this session/i)).toBeInTheDocument(); + }); + + it("requires a SessionProvider", () => { + // Rendering without a provider intentionally throws. React additionally logs + // the caught render error to console.error; silence that expected noise for + // this test only so a real future error here stays visible. + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(() => + render( + + + , + ), + ).toThrow(/SessionProvider/); + } finally { + errorSpy.mockRestore(); + } + }); +}); diff --git a/src/pages/WorkbenchPage.tsx b/src/pages/WorkbenchPage.tsx new file mode 100644 index 0000000..89c2e1b --- /dev/null +++ b/src/pages/WorkbenchPage.tsx @@ -0,0 +1,172 @@ +import { Link, useNavigate } from "react-router-dom"; +import { ArrowRight, FlaskConical, RotateCcw, Send, Trash2 } from "lucide-react"; +import { ModuleLayout } from "@/components/layout/ModuleLayout"; +import { FindingsList } from "@/components/workbench/FindingsList"; +import { computeAnalytics } from "@/lib/workbench/analytics"; +import { deriveReviewItems } from "@/lib/workbench/review"; +import { useSession, type SessionDocument } from "@/lib/workbench/session"; + +const HANDOFF_TARGETS: { id: string; label: string }[] = [ + { id: "storyteller", label: "Storyteller" }, + { id: "scrubber", label: "Scrubber" }, + { id: "cbpr", label: "CBPR+" }, + { id: "insights", label: "Insights" }, +]; + +export function WorkbenchPage() { + const navigate = useNavigate(); + const { documents, results, handoff, stageHandoff, clearHandoff, removeDocument, clearSession } = + useSession(); + + const analytics = computeAnalytics(results, documents.length); + const openReview = deriveReviewItems(results).length; + const findings = results.flatMap((result) => result.findings); + + const sendTo = (doc: SessionDocument, target: string) => { + stageHandoff({ targetModuleId: target, label: doc.label, content: doc.content }); + navigate(`/${target}`); + }; + + const metrics: { label: string; value: string }[] = [ + { label: "Documents", value: String(documents.length) }, + { label: "Analyses", value: String(analytics.totalResults) }, + { label: "Findings", value: String(analytics.totalFindings) }, + { label: "Open review", value: String(openReview) }, + { label: "Pass rate", value: `${Math.round(analytics.passRate * 100)}%` }, + ]; + + return ( + + + + ); +}