Compare commits

..

5 Commits

Author SHA1 Message Date
Devin
8dcdb4531c PR E: SWIFT gateway (MT760, pacs.009, MT202, camt.025/054) — arch step 6
Outbound generators:
- swift/mt760.ts: SBLC issuance (FIN Cat-7). 12-tag message built from
  InstrumentTerms with deterministic messageHash() for planHash
  anchoring. URDG 758 / UCP 600 aware.
- swift/pacs009.ts: FI-to-FI credit transfer (ISO 20022 XML,
  pacs.009.001.08). Fixes the pacs.008 mis-routing flagged in the
  gap-analysis (pacs.008 is customer-to-bank; pacs.009 is bank-to-bank).
  BIC validation on all four agents.
- swift/mt202.ts: FIN equivalent of pacs.009 for non-migrated corridors.
  32A amount formatted with SWIFT decimal comma.

Inbound parsers:
- swift/camt.ts: parseCamt025 (receipt / status), parseCamt054
  (credit/debit notification), reconcileCamt054 (diffs amount, ccy,
  direction, endToEndId so VALIDATING can feed mismatches into
  Data.valueMismatch()), parseCamt dispatcher on xmlns marker.

Public surface in swift/index.ts documents channel selection:
pacs.008 stays on the PSP customer leg; pacs.009/MT202 is the
interbank leg; COMMIT requires camt.025 ACSC or camt.054 CRDT
(arch §9.2 accepted !== settled).

Tests: swift.test.ts — 14 cases covering the happy path, validation
errors (bad BIC, malformed date, negative amount, missing pay step),
determinism of messageHash, camt parser + reconciliation.

tsc clean. 74 tests pass across 6 suites.
2026-04-22 16:42:21 +00:00
Devin
18bdaf61d5 PR D: typed + signed event bus + events table + SSE (arch step 5)
- db/migrations/003_events.ts: append-only events table with
  payload_hash, prev_hash, HMAC signature, indexed by plan_id + type
- services/eventBus.ts: EVENT_TYPES union (all 15 arch §7.2
  categories), publish() with hash-chain + HMAC signing, verifyChain()
  for tamper detection, subscribe() via in-process EventEmitter
- api/plans.ts:
    - GET /api/plans/:planId/events (?verify=1 returns chain_valid)
    - GET /api/plans/:planId/events/stream (SSE with history replay +
      live push, 15s keep-alive, clean unsubscribe on client disconnect)
- index.ts: register the two new endpoints
- tests/unit/eventBus.test.ts: 9 tests covering publish, hash chain,
  per-plan isolation, and three tamper-detection scenarios (payload,
  signature, prev_hash)

60 tests pass. tsc clean.
2026-04-22 16:37:45 +00:00
Devin
5bd6a200c3 PR C: wire real NotaryRegistry contract on Chain 138 (arch step 4)
Some checks failed
Code Quality / SonarQube Analysis (pull_request) Failing after 20s
Code Quality / Code Quality Checks (pull_request) Failing after 8s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 3s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
- services/notaryChain.ts: new ethers-v6 adapter speaking to the
  deployed NotaryRegistry.sol via CHAIN_138_RPC_URL +
  NOTARY_REGISTRY_ADDRESS + ORCHESTRATOR_PRIVATE_KEY. Exposes
  anchorPlan(plan) -> { mode, txHash, planHash, blockNumber } and
  finalizeAnchor(planId, success) -> { mode, txHash, receiptHash }
  with deterministic mock fallback when envs are absent.
- services/notary.ts: refactored to delegate to notaryChain; preserves
  the prior signature and returns extra on-chain fields (mode, txHash,
  blockNumber, contractAddress) when the anchor lands.
- config/env.ts: add CHAIN_138_RPC_URL, CHAIN_138_CHAIN_ID,
  NOTARY_REGISTRY_ADDRESS, ORCHESTRATOR_PRIVATE_KEY (all optional,
  validated via regex where applicable).
- package.json: add ethers@^6.11.0 dependency.
- tests/unit/notaryChain.test.ts: 6 tests covering deterministic
  hashing helpers and the mock fallback path.

tsc clean. 51 tests pass (45 pre-existing + 6 new).
2026-04-22 16:33:06 +00:00
Devin
908c386dff PR B: VALIDATING phase + unified ExceptionManager (arch steps 3, 7)
- services/exceptionManager.ts: single taxonomy (timing/data/control/
  business/system) with §12 codes, deterministic route() table, and
  handle() dispatch to retry/DLQ/escalate
- services/execution.ts: refactor executePlan to drive the full 12-state
  machine (DRAFT -> INITIATED -> ... -> VALIDATING -> COMMITTED -> CLOSED)
  via stateMachine.transition(), with a new validatePhase() that
  reconciles DLT tx hash + bank message id + per-step amounts before
  COMMIT; SoD-gated edges use distinct synthetic actors by default
- api/plans.ts + index.ts: GET /api/plans/:planId/state returning
  current transaction_state + full audit trail of transitions
- tests/unit/exceptionManager.test.ts: 14 tests for classification +
  routing matrix

59 tests pass. tsc clean.
2026-04-22 16:29:21 +00:00
Devin
b24a4df983 PR A: 12-state transaction machine + issueInstrument step + SoD matrix
Some checks failed
Code Quality / SonarQube Analysis (pull_request) Failing after 23s
Code Quality / Code Quality Checks (pull_request) Failing after 11s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 5s
Architecture note steps 1, 2, 10 (data model).

- types/transactionState.ts: 12 states, allowed-transition table, SoD matrix
- types/plan.ts: add InstrumentTerms + 'issueInstrument' PlanStep type
- services/planValidation.ts: validate SBLC step (BIC, ISO-4217, sha256,
  YYYY-MM-DD expiry, >0 amount)
- services/stateMachine.ts: transition() enforces legality + SoD + appends
  to transaction_state_transitions
- db/migrations/002: plans.transaction_state (CHECK) +
  transaction_state_transitions append-only table
- tests/unit: 13 + 8 unit tests (31 total, all pass)

No behaviour change yet: coordinator still uses legacy status field.
PRs B-G will migrate execution paths onto the new machine.
2026-04-22 16:21:36 +00:00
121 changed files with 53 additions and 21792 deletions

View File

@@ -1,63 +0,0 @@
# Testing TransactFlow IDE
## Overview
TransactFlow is a React 18 + TypeScript + Vite app using @xyflow/react for a drag-and-drop graph editor. It deploys as a static frontend site (no backend).
## Deployed URL
https://dist-dgoompqy.devinapps.com
## Local Dev
```bash
cd /home/ubuntu/repos/transaction-builder
npm install
npm run dev # starts on localhost:5174
```
## Testing Approach
### Tool Selection
- **Browser tool**: Use for most UI interactions (clicking, typing, verifying DOM state via console). Works well for buttons, inputs, tabs, dropdowns.
- **Playwright CDP**: Required for drag-and-drop testing. React Flow's drag-and-drop uses native browser events that synthetic DOM events cannot replicate. Connect via `chromium.connectOverCDP('http://127.0.0.1:<port>')`. The Chrome CDP port may be ephemeral — find it with `ss -tlnp 2>/dev/null | grep chrome`.
- **Browser console**: Use `document.querySelector`/`querySelectorAll` for DOM assertions. Returns exact counts and text content for verification.
### Key Patterns
#### React Controlled Inputs
React controlled inputs (`<input value={state} onChange={...}>`) cannot be cleared via `element.value = ''` + synthetic events. React manages the value internally. **Workaround**: Reload the page to reset state rather than fighting React's control.
#### devinid Drift After DOM Changes
The browser tool assigns `devinid` attributes based on current DOM state. After significant DOM changes (collapsing accordions, opening modals, switching tabs), cached devinids become invalid. **Workaround**: Re-query the HTML or reload the page after major DOM mutations to get fresh devinids.
#### Keyboard Shortcuts (Ctrl+K, Ctrl+B, etc.)
Browser automation tools may intercept `Ctrl+K` before it reaches the page. Synthetic `KeyboardEvent` dispatch on `window` also might not trigger React's state updates reliably. **Workaround**: Use the UI button that triggers the same action (e.g., Command Palette icon button instead of Ctrl+K).
#### Command Palette
The command palette overlay renders as `.command-palette` with input `.command-palette-input` and results `.command-palette-results`. Commands are `.command-item` elements with `.command-label` text. The palette input might not have a visible devinid in truncated HTML — search the full page HTML file for `placeholder="Type a command"`.
### Component Architecture (for test assertions)
- **TitleBar**: Mode selector (`.mode-selector` / `.mode-dropdown` / `.mode-option`), search bar, validate/simulate/execute buttons
- **ActivityBar**: 10 `.activity-btn` icon buttons
- **LeftPanel**: Search input, filter buttons (All/Favorites/Recent), `.component-category` with `.category-header` and `.category-items`, `.component-item` elements
- **Canvas**: React Flow container, `.canvas-empty-content` (shown when `nodes.length === 0`), `.canvas-inspector` with node/connection counts
- **RightPanel**: `.chat-header-agent` shows active agent name, `.agent-tab` buttons for switching, `.chat-message.user` and `.chat-message.agent` for messages, agent responses have 800ms delay
- **BottomPanel**: `.bottom-tab` buttons, `.system-800-card` (6 cards), `.settlement-table` (4 rows), `.audit-content` with `.audit-entry` elements
- **CommandPalette**: `.command-palette-overlay`, `.command-palette`, `.command-item`, `.command-label`
### Test Data (hardcoded in source)
- Default favorites: Transfer, Swap, KYC (Set in LeftPanel.tsx)
- 7 component categories, 56 total components
- 7 agent types: Builder, Compliance, Routing, ISO-20022, Settlement, Risk, Documentation
- 6 system status cards in 800 System tab
- 4 settlement queue rows (includes TX-2024-0847)
- Audit trail contains SESSION_START and ENGINE_LOAD events
- 4 session modes: Sandbox, Simulate, Live, Compliance Review
- Builder agent responds with "Transfer" (when input doesn't contain "swap") or "Swap" (when it does)
- Compliance agent responds with "No policy violations"
## Devin Secrets Needed
None — this is a purely frontend static site with no auth required.
## Common Issues
- If Chrome CDP port is not 29229, the browser was launched with `--remote-debugging-port=0` (random). Restart browser via `browser(action="restart")` and find the new port.
- React Flow nodes require native browser drag events. Use Playwright's `dragTo()` method, not synthetic `DragEvent` dispatch.
- After page reload, all React state resets (nodes cleared, chat history cleared, filters reset to defaults). Plan test sequences accordingly.

View File

@@ -1,22 +0,0 @@
name: Deploy to Phoenix
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Trigger Phoenix deployment
run: |
SHA="$(git rev-parse HEAD)"
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
curl -sSf -X POST "${{ secrets.PHOENIX_DEPLOY_URL }}" \
-H "Authorization: Bearer ${{ secrets.PHOENIX_DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"repo\":\"${{ gitea.repository }}\",\"sha\":\"${SHA}\",\"branch\":\"${BRANCH}\",\"target\":\"default\"}"

View File

@@ -12,6 +12,7 @@ jobs:
name: Frontend Lint
runs-on: ubuntu-latest
steps:
<<<<<<< HEAD
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
@@ -29,6 +30,7 @@ jobs:
name: Frontend Type Check
runs-on: ubuntu-latest
steps:
<<<<<<< HEAD
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
@@ -46,6 +48,7 @@ jobs:
name: Frontend Build
runs-on: ubuntu-latest
steps:
<<<<<<< HEAD
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
@@ -68,6 +71,7 @@ jobs:
name: Frontend E2E Tests
runs-on: ubuntu-latest
steps:
<<<<<<< HEAD
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
@@ -95,6 +99,7 @@ jobs:
name: Orchestrator Build
runs-on: ubuntu-latest
steps:
<<<<<<< HEAD
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
@@ -108,61 +113,12 @@ jobs:
working-directory: orchestrator
run: npm run build
orchestrator-test:
name: Orchestrator Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: "18"
cache: "npm"
cache-dependency-path: orchestrator/package-lock.json
- name: Install dependencies
working-directory: orchestrator
run: npm ci
- name: Type check
working-directory: orchestrator
run: npx tsc --noEmit
- name: Unit tests
working-directory: orchestrator
run: npm test
orchestrator-e2e:
name: Orchestrator E2E (Testcontainers)
runs-on: ubuntu-latest
# Gap-analysis v2 §7.8 / §10.8 — opt-in E2E suite that brings up
# a real Postgres container and exercises the lifecycle against it.
# Gated on a workflow label so PR runs default to the fast unit
# suite; add the `run-e2e` label to a PR to include this job.
if: contains(github.event.pull_request.labels.*.name, 'run-e2e') || github.event_name == 'push'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: "18"
cache: "npm"
cache-dependency-path: orchestrator/package-lock.json
- name: Install dependencies
working-directory: orchestrator
run: npm ci
- name: E2E tests (Testcontainers Postgres + public Chain 138 RPC)
working-directory: orchestrator
# EXT-CHAIN138-CI-RPC resolved via the public endpoint at
# https://rpc.public-0138.defi-oracle.io — the read-only
# public-RPC suite exercises the orchestrator's ethers client
# against a real Chain 138 node alongside the ganache-based
# round-trip tests. The env var opts the public-RPC suite in;
# without it, those tests self-skip.
env:
E2E_USE_PUBLIC_CHAIN138: "1"
run: npm run test:e2e
# Smart Contracts CI
contracts-compile:
name: Contracts Compile
runs-on: ubuntu-latest
steps:
<<<<<<< HEAD
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
@@ -180,6 +136,7 @@ jobs:
name: Contracts Test
runs-on: ubuntu-latest
steps:
<<<<<<< HEAD
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:

23
.gitignore vendored
View File

@@ -5,17 +5,14 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-debug.log*
lerna-debug.log*
# Build outputs
dist/
dist-ssr/
build/
.next/
out/
.vercel/
*.tsbuildinfo
*.local
# Environment variables
.env
@@ -26,17 +23,10 @@ out/
.env*.local
# IDE
.vscode/*
!.vscode/extensions.json
.vscode/
.idea/
.idea
*.swp
*.swo
*.sw?
*.suo
*.ntvs*
*.njsproj
*.sln
*~
.DS_Store
@@ -46,14 +36,9 @@ coverage/
playwright-report/
test-results/
playwright/.cache/
test-*.mjs
test-*.md
screenshot-*.png
screenshots/
# Logs
logs/
logs
*.log
# Hardhat
@@ -69,12 +54,18 @@ temp/
# OS
Thumbs.db
.DS_Store
# Package managers
package-lock.json
yarn.lock
pnpm-lock.yaml
# TypeScript
*.tsbuildinfo
# Misc
*.pem
*.key
.vercel

View File

@@ -16,8 +16,6 @@ contract NotaryRegistry is INotaryRegistry, Ownable {
event PlanFinalized(bytes32 indexed planId, bool success, bytes32 receiptHash);
event CodehashRegistered(address indexed contractAddress, bytes32 codehash, string version);
constructor(address initialOwner) Ownable(initialOwner) {}
/**
* @notice Register a plan with notary
*/

View File

@@ -1,236 +0,0 @@
# Architecture Note — Amendments
**Reference:** *Multi-Layer Atomic Settlement Architecture for SBLC Issuance and
Payment Coordination* (Draft 1.0).
**Purpose:** Three amendments identified during the CurrenciCombo gap-analysis
(§2 of `docs/ADRs` / gap-analysis note) that tighten the contract between the
note and the orchestrator implementation landing in PRs AG.
These amendments are **normative**: where the text here conflicts with the
original draft, this document takes precedence.
---
## Amendment 1 — §5.1 Transaction Coordinator (trust model)
### Problem
The original §5.1 names the Transaction Coordinator but does not specify **who
runs it** or **what trust assumptions the other participants must accept** to
use it. In a multi-bank SBLC + payment flow this is not a detail — the
Coordinator holds the state registry, issues `transaction.prepared`
instructions, and decides `COMMITTED` vs `ABORTED`. Whoever runs it is, in
effect, the workflow authority.
Three candidate topologies exist:
1. **Single-party hosted** — one participant (e.g. the issuing bank, the
beneficiary's bank, or a shared utility) runs a single Coordinator instance
and the rest consume its API.
2. **Federated** — each participant runs their own Coordinator; they reach
consensus over the state via signed events exchanged peer-to-peer (the
architecture note §7 normalised events).
3. **Neutral third-party utility** — a non-participant (e.g. a FinTech utility,
a central bankadjacent entity, or an SRO) runs the Coordinator under a
published operating model.
### Amendment text (replaces §5.1)
> **5.1 Transaction Coordinator.** Central orchestration service that manages
> the lifecycle of a transaction instance. The operator of the Coordinator
> SHALL be named in the governing documents (§4.1) as the *Workflow Authority*.
> The Workflow Authority:
>
> - is a single named legal entity for any given transaction;
> - MUST be a participant in, or a party contractually bound to, that
> transaction's governing documents;
> - MUST NOT be the same entity that provides the Identity and Authorization
> Service (§5.8) or the Ledger Anchor (§5.7) — separation of the control
> plane, the trust anchor, and the audit anchor is a requirement, not an
> option;
> - MUST publish its operating model, availability commitments, and exception
> escalation paths to all participants;
> - MUST sign every state transition it records to the State Registry (§5.6)
> with a key bound to its identity in the Identity and Authorization
> Service; participants verify those signatures before accepting a state as
> canonical.
>
> CurrenciCombo's reference topology is (1) **single-party hosted**, where
> the issuing bank of the SBLC operates the Coordinator and the payment-side
> bank, beneficiary, and applicant consume its API. Federated (2) and
> neutral-utility (3) topologies are out of scope for v1 but are not
> prohibited — they can be layered on top by replacing the Coordinator
> implementation while preserving the API surface.
### Implementation impact
- **`orchestrator` (CurrenciCombo)**: the orchestrator IS the Coordinator.
`NOTARY_REGISTRY_ADDRESS` (Ledger Anchor) and the signing key used for the
event bus (§5.8 / PR D `EVENT_BUS_SECRET`) must be held in separate key
stores. PR A's SoD matrix (`stateMachine.ts`) already prevents a single
actor from driving the 4 SoD-gated transitions.
- **Operational**: the config (`orchestrator/src/config/env.ts`) SHOULD grow
a `WORKFLOW_AUTHORITY_NAME` + `WORKFLOW_AUTHORITY_JWK_URL` pair so
consumers can resolve and verify the Coordinator's identity without
out-of-band trust. Tracked as a follow-up ticket; not blocking.
---
## Amendment 2 — §9.2 Commit Rule ("accepted ≠ settled")
### Problem
§9.2 currently reads:
> A transaction may enter **COMMITTED** only when:
> - the instrument leg has produced valid dispatch evidence
> - the payment leg has produced valid settlement or **accepted completion
> evidence**
> - all key transaction attributes reconcile against expected values
> - no outstanding exception blocks remain
The phrase "accepted completion evidence" is too loose. In SWIFT and ISO 20022
terms, **acceptance is not settlement**:
| Message | Meaning | Is settlement? |
|------------------|-------------------------------------------------|----------------|
| `pacs.002 ACCP` | Instruction technically accepted by receiver | **No** |
| `pacs.002 ACSP` | Accepted, settlement in process | **No** |
| `pacs.002 ACSC` | Accepted, settlement completed | Yes |
| `camt.025 ACCP` | Receipt: accepted | **No** |
| `camt.025 ACSC` | Receipt: settlement completed | Yes |
| `camt.054 CRDT` | Account credit notification | Yes (on receiver) |
| MT910 | Confirmation of credit | Yes |
| MT900 | Confirmation of debit | Yes (on sender) |
Treating `ACCP` as sufficient for `COMMITTED` introduces a window where the
Coordinator has locked-in the issuance but the payment has not cleared — the
exact failure mode the two-phase commit was meant to prevent.
### Amendment text (replaces §9.2)
> **9.2 Commit Rule.** A transaction may enter **COMMITTED** only when **all**
> of the following are true:
>
> 1. The instrument leg has produced valid dispatch evidence: an authenticated
> `MT760` issuance acknowledgment or an ISO 20022 instrument-specific
> equivalent, signed and time-stamped.
> 2. The payment leg has produced valid **settlement** evidence — not merely
> acceptance. Valid settlement evidence is one of:
> - `pacs.002` with status `ACSC` on the pacs.009 / pacs.008 interbank
> leg;
> - `camt.025` with status `ACSC`;
> - `camt.054` credit notification referencing the expected
> `EndToEndIdentification`, `InstructedAmount`, and `Currency`;
> - An `MT910` (credit confirmation) on the beneficiary side or an
> `MT900` (debit confirmation) on the originator side with matching
> transaction reference and amount.
> 3. The Coordinator has run the `VALIDATING` phase (§4.3 / PR B) and all
> reconciliation checks have passed — in particular amount, currency,
> credit/debit direction, and end-to-end identifier.
> 4. No outstanding exception blocks remain in the Exception Manager (§5.9 /
> PR B).
>
> `ACCP` / `ACSP` / `PDNG` statuses SHALL NOT satisfy (2) on their own. If
> only acceptance-level evidence has arrived and the settlement-deadline
> timer has not expired, the transaction remains in `VALIDATING`. On timer
> expiry, the Coordinator transitions to `ABORTED` under §9.3 timing-exception
> rules.
### Implementation impact
- **`orchestrator/src/services/swift/camt.ts` (PR E)**: `parseCamt025` already
distinguishes `ACCP | ACSC | ACSP | RJCT | PDNG`. The
`ExecutionCoordinator` must only accept `ACSC` (camt.025) or `CRDT`
(camt.054 matching reconciliation) as the settlement trigger. **Tracked
follow-up**: wire this into `executionCoordinator.validatePlan()` so that
`VALIDATING → COMMITTED` is blocked when the latest camt message is
`ACCP` / `ACSP`. Current code does not reference these statuses yet;
correctness is preserved today only because the mocked dispatch always
synthesises `ACSC`.
- **`orchestrator/src/services/exceptionManager.ts` (PR B)**: add a
`Timing.settlementDeadlineExpired` class routed to `ABORTED`. Current
taxonomy has generic `Timing.dispatchTimeout` / `acknowledgmentDelay`
which is too coarse for this distinction.
---
## Amendment 3 — §4.1 UNWIND_PENDING matrix (MT760 irrevocability)
### Problem
§8.1 defines a single `UNWIND_PENDING` state after `ABORTED`, and §11 Phase 6
says "if needed, initiate unwind process". This glosses over a hard banking
fact: **an issued MT760 guarantee/SBLC is irrevocable under UCP 600 / URDG
758** once it has been dispatched to the beneficiary's bank. The set of
"unwind" actions is therefore not uniform — it depends on *which leg* has
progressed how far.
The original note's state diagram implies `UNWIND_PENDING` is reachable from
any `ABORTED`, regardless of what was already dispatched. That is
operationally wrong: if the MT760 has left the issuing bank and been
authenticated by the beneficiary's bank, the instrument itself **cannot be
withdrawn unilaterally** — it can only be discharged (on expiry or on
beneficiary release) or replaced by a counter-instrument.
### Amendment text (adds §4.1.1 and refines §8.1 / §11 Phase 6)
> **4.1.1 Instrument irrevocability matrix.** The `UNWIND_PENDING` state
> SHALL NOT imply that the instrument leg is reversible. The set of unwind
> actions available depends on the observable state of each leg at the
> moment of `ABORTED`:
>
> | Instrument-leg observable state | Instrument unwind action |
> |--------------------------------------------|--------------------------|
> | `instrument.dispatched` not yet emitted | **Withdraw** — cancel before dispatch. No counter-instrument needed. |
> | `instrument.dispatched` emitted, `instrument.acknowledged` not yet emitted | **Recall request** — non-binding; beneficiary's bank MAY reject. If rejected, fall through to "acknowledged" row. |
> | `instrument.acknowledged` emitted | **Irrevocable — no unwind available.** The instrument stands until expiry (§11 tenor) or beneficiary-side release. The only control-plane actions are (a) accelerated expiry on mutual written consent, (b) issuance of a **counter-guarantee** from the beneficiary of the original instrument back to the applicant, (c) legal discharge via governing-law procedure. |
>
> | Payment-leg observable state | Payment unwind action |
> |--------------------------------------------|--------------------------|
> | `payment.dispatched` not yet emitted | **Withhold** — do not dispatch. |
> | `payment.dispatched` emitted, `payment.accepted` not yet emitted | **Recall** (`pacs.028` request-to-modify / `camt.056` request-for-cancellation). Best-effort. |
> | `payment.accepted` but not `payment.settled` | **Recall before settlement** — MAY succeed depending on receiver bank's processing window. |
> | `payment.settled` | **Return payment** — requires a fresh, separately-instructed return payment (`pacs.009` in reverse direction). Not an unwind of the original; a compensating transfer. |
>
> `UNWIND_PENDING` is a **state of the orchestrator**, not a guarantee that
> the underlying banking artefacts can be reversed. On entry to
> `UNWIND_PENDING`, the Coordinator SHALL record in the State Registry the
> observable state of each leg at the moment of `ABORTED` and the unwind
> action selected from the matrix above.
### Implementation impact
- **`orchestrator/src/services/stateMachine.ts` (PR A)**: the transition
table is unchanged (`ABORTED → UNWIND_PENDING → CLOSED` remains valid).
What changes is the *payload* recorded on the `ABORTED → UNWIND_PENDING`
transition — the `reason` field must include the instrument-leg and
payment-leg observable states.
- **`orchestrator/src/services/execution.ts`**: on entry to
`UNWIND_PENDING`, the Coordinator must select and persist the unwind
actions per the matrix. **Tracked follow-up**: this requires the
ExecutionCoordinator to consume real SWIFT events (PR E outbound +
inbound parsers are in place; wiring them to drive `instrument.dispatched`
/ `instrument.acknowledged` / `payment.*` events is a separate
coordinator-focused PR).
- **Portal `/transactions` page (PR G)**: the audit-trail card already
renders `reason` inline; no UI change required. The unwind-action
tracking will naturally surface as additional event rows.
---
## Summary of downstream tickets
Tracked as separate work items, not blockers for AG:
1. `WORKFLOW_AUTHORITY_NAME` + JWK URL in orchestrator env (Amendment 1).
2. Wire `executionCoordinator.validatePlan()` to discriminate `ACCP`/`ACSP`
from `ACSC`/`CRDT` using the PR E parsers (Amendment 2).
3. Add `Timing.settlementDeadlineExpired` to the Exception taxonomy
(Amendment 2).
4. Capture instrument-leg and payment-leg observable state in the
`ABORTED → UNWIND_PENDING` transition `reason` field (Amendment 3).
5. Persist the selected unwind action per the matrix in Amendment 3.
None of the five items regress AG — they extend behaviour on top of
already-landed structures.

View File

@@ -1,291 +0,0 @@
# Responsive UX/UI strategy
This document describes the responsive design system added to the
CurrenciCombo portal. It is intended to be the source of truth for
future responsive work.
## Goals
The portal ships to a mixed audience that includes treasury operators
at desks (≥ 1440 px displays), branch-office users on 13" laptops,
tablets in landscape, and mobile phones for on-call auditors. The
system has to adapt across all of those without:
- horizontal scroll at any viewport ≥ 320 px,
- layout shift on resize (no CLS),
- unusable tap targets on touch devices,
- losing keyboard navigation on desktop, or
- degrading desktop performance.
## Architecture
The responsive system is CSS-first and token-driven. There is **one**
runtime hook (`useBreakpoint`) used by the handful of components that
have to swap structure (nav, workspace gate) — everything else is
pure CSS.
### Layered stylesheet order
`src/main.tsx` imports styles in this order:
1. `src/styles/tokens.css` — design tokens (no visual side effects).
2. `src/index.css` — the original desktop baseline stylesheet.
3. `src/styles/responsive.css` — media-query overrides.
4. `src/styles/a11y.css` — focus ring, skip-to-content, sr-only,
touch-target enforcement.
Order matters: overrides must appear after the baseline so that rules
at equal specificity win. Nothing in `responsive.css` or `a11y.css`
changes behavior at `≥ lg` — every rule is wrapped in a
`@media (max-width: …)` or `@media (pointer: coarse)` query.
### Tokens
All sizing values that change with viewport live in
`src/styles/tokens.css`:
| Category | Tokens |
| ------------- | -------------------------------------------- |
| Breakpoints | `--bp-xs 0`, `--bp-sm 480`, `--bp-md 768`, `--bp-lg 1024`, `--bp-xl 1440` (px) |
| Fluid type | `--fs-2xs` through `--fs-4xl`, all `clamp()` |
| Fluid spacing | `--space-0` through `--space-12`, all `clamp()` |
| Line height | `--lh-tight`, `--lh-snug`, `--lh-normal`, `--lh-relaxed` |
| Motion | `--motion-fast 120ms`, `--motion-base 200ms`, `--motion-slow 320ms`, `--motion-ease` |
| Z-index | `--z-base`, `--z-sticky`, `--z-drawer-backdrop`, `--z-drawer`, `--z-dropdown`, `--z-modal`, `--z-toast`, `--z-tooltip`, `--z-focus` |
| Focus ring | `--focus-ring-color #60a5fa`, `--focus-ring-offset 2px`, `--focus-ring-width 2px` |
| Safe area | `--safe-top/right/bottom/left` via `env()` |
| Tap target | `--tap-min 44px` |
### Fluid scaling formula
Typography and spacing use `clamp(min, preferred, max)` where
`preferred` has a `vw` term so values interpolate continuously between
the smallest design viewport (320 px) and the largest (1440 px). This
is what the spec calls "avoid hard breakpoints where possible; prefer
fluid responsiveness" — between breakpoints, sizes grow continuously
rather than snapping.
Example, `--fs-base: clamp(0.8125rem, 0.77rem + 0.23vw, 1rem)`:
- at 320 px → 13 px (minimum),
- at 1024 px → ~15.1 px,
- at 1440 px → 16 px (maximum clamp).
### Breakpoints
| Name | CSS range | Semantic |
| ---- | ----------------------- | ---------------------- |
| xs | 0 479 px | Small phones, portrait |
| sm | 480 767 px | Larger phones |
| md | 768 1023 px | Tablets |
| lg | 1024 1439 px | Laptops |
| xl | ≥ 1440 px | External displays |
Semantic aliases exposed by `useBreakpoint()`:
- `isMobile` = `< md` (phones)
- `isTablet` = `md lg` (tablets, portrait iPads)
- `isDesktop` = `≥ lg` (laptops, external monitors)
### Hooks
- **`useMediaQuery(query)`** — subscribes to a matchMedia `change`
event via `useSyncExternalStore`. No resize listener — re-renders
only when the query result actually flips.
- **`useBreakpoint()`** — returns `{ current, isXs/Sm/Md/Lg/Xl,
isMobile/Tablet/Desktop }`. Composed of four `useMediaQuery` calls
using the `--bp-*` values from the tokens.
- **`useReducedMotion()`** — wraps `(prefers-reduced-motion: reduce)`
so components that animate in JS can disable animations.
- **`useOrientation()`** — returns `'portrait'` or `'landscape'`.
All four hooks are SSR-safe (return `false` / default in a non-DOM
environment).
## Component inventory and strategy
### Global primitives
- **Skip-to-content link** — rendered at the top of the tree via
`<SkipToContent>`. Visually hidden until `:focus`, then slides in at
top-left. Target is `<main id="main-content" tabIndex={-1}>` in
`PortalLayout`.
- **VisuallyHidden** — `sr-only` wrapper, for adding accessible text
without visual presence.
### Portal chrome
- **Topbar** (`.portal-topbar`): fixed 48 px height at all sizes.
Below `md`, the "Production" environment badge and the "Solace Bank
Group" logo text are hidden so the hamburger, logo mark and user
avatar fit on a phone without wrapping.
- **Hamburger toggle** (`.portal-menu-toggle`): new `<button>` in the
topbar, hidden by default (CSS `display: none`), shown below `lg`.
44 × 44 tap area. `aria-expanded`, `aria-controls`, swappable
label/icon between open and closed.
- **Sidebar / drawer** (`.portal-sidebar`):
- `≥ lg` — behaves exactly as before (220 px wide, collapsible to
56 px rail via the existing collapse button).
- `md lg` — forced into a 56 px icon-only rail (tablet treatment).
- `< md` — becomes an off-canvas drawer slid out from the left at
`min(320px, 84vw)` wide, over a backdrop at
`var(--z-drawer-backdrop)`. Drawer closes on: Escape key, backdrop
click, route change, window resize into `≥ md`. Body scroll is
locked while the drawer is open.
### Dashboard (`/dashboard`)
- KPI row uses `grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr))`
so six cards at xl collapse to four at lg, three at md, two at sm,
one at xs — fluidly.
- Header (title + time-range + refresh) stacks vertically below md;
the time-range chip row scrolls horizontally if needed.
- Two-column `.dashboard-grid` becomes single-column below `lg`.
### Tables (Transactions, Settlements, Accounts)
Two CSS patterns available:
1. **Scrolling wrapper (default).** Wrap the `<table>` in
`<div class="portal-table-wrapper">`. Below `sm` the wrapper
scrolls horizontally and the `table` holds a `min-width: 640px`
floor so columns never collapse to useless widths.
2. **Stacked-card mode (opt-in).** Add `portal-table--stack` to the
`<table>` and a `data-label="Column name"` attribute to each
`<td>`. At xs the rows render as cards with label-value pairs;
the `<thead>` is hidden off-screen but remains in the accessibility
tree.
### Forms (Reporting, Compliance, Treasury, Settings)
These pages already use CSS grid via the `.settings-grid` /
`.treasury-summary` / etc. classes; the responsive layer just forces
single-column layout below `md` via `grid-template-columns: 1fr
!important` and reduces container padding to `var(--space-4)` on
phones.
### Login page
- Below `md` the two-column layout (brand left / card right) stacks.
Both halves go to `max-width: 100%`.
- `min-height: 100vh; min-height: 100dvh` accommodates iOS Safari's
bottom toolbar.
- `safe-area-inset-*` padding for notched phones.
### Workspace / IDE mode (`App.tsx`)
The multi-panel IDE (TitleBar + ActivityBar + LeftPanel + Canvas +
RightPanel + BottomPanel + react-flow) is not realistically usable
below ~768 px of width. Matching the pattern used by VS Code Web,
Figma, and Replit:
- **`< md`** — show `<WorkspaceMobileGate>` instead of the IDE shell.
Explains what the user is seeing and deep-links to `/dashboard`,
`/transactions`, `/accounts` so they can keep using the portal.
- **`≥ md`** — render the full workspace exactly as before.
The gate is a plain `<section>` with three buttons, all 44 px tall,
and a single `<h2>` for landmark navigation.
## Performance
- **No layout shift.** Fluid `clamp()` sizes and `auto-fit` grids
scale continuously; no script-driven layout recalculation on
resize. `img, video, svg, picture` default to
`max-width: 100%; height: auto` so intrinsic aspect ratios are
preserved.
- **No resize listeners.** `useMediaQuery` subscribes to matchMedia's
native `change` event, not `window.resize`. React only re-renders
when a query actually flips.
- **CSS-first.** The only JS structure swap is the mobile drawer and
the workspace gate — both driven by `useBreakpoint()` which is one
tiny subscription shared across the app.
- **Print.** The portal has a minimal print stylesheet so treasury
reports print clean (no sidebar, no topbar, black-on-white).
## Accessibility
- **Skip-to-content link** at the top of the tree.
- **Focus ring** via `:focus-visible`, 2 px solid `#60a5fa` with
2 px offset. Defined in `a11y.css`, tightens to 3 px in
`prefers-contrast: more`.
- **Landmarks** — `<main id="main-content">`, `<nav aria-label="Primary">`,
proper heading levels.
- **Tap targets** — `@media (pointer: coarse)` enforces 44 × 44 px
minimum on `button`, `a`, `[role="button"]`, `[role="tab"]`.
- **Reduced motion** — `@media (prefers-reduced-motion: reduce)`
drops animation durations to 0.001 ms; JS-driven animations read
`useReducedMotion()`.
- **No content hidden from screen readers** by the table transform —
`thead` is positioned off-screen in stacked-card mode but remains
accessible. Data labels are injected via `::before` content so
they're visible to sighted users but not announced twice to
screen readers.
## Gotchas and non-obvious decisions
1. **`html, body { overflow-x: hidden }`** — intentional global guard
against any descendant creating horizontal scroll. The IDE shell
(`.app-shell`) and portal root (`.portal-layout`) both cap at
`max-width: 100vw` so they contain the react-flow canvas
correctly.
2. **Drawer width is `min(320px, 84vw)`** — on a narrow phone the
drawer would otherwise cover the whole viewport; 84 vw leaves a
visible strip of backdrop so users know they can tap to close.
3. **`.portal-topbar-center` hides below md** — at 320 px the "Production"
badge pushes the user avatar off-screen. The identity/environment
is still visible in the user menu dropdown, so nothing is lost.
4. **Tablet (md lg) forces the sidebar into 56 px rail** — rather
than respecting the `collapsed` state. Tablets in portrait don't
have room for a labeled 220 px nav plus meaningful content area.
5. **`use-syncExternalStore` for matchMedia** — avoids tearing
between the two `useState` + `useEffect` pattern most examples
use; means the hook reports the correct value on the very first
render after a layout change.
6. **`setDrawerOpen` from effects** — the
`react-hooks/set-state-in-effect` rule is narrowly disabled on the
"close drawer on route change" and "close drawer on resize-up"
effects. These are synchronizing internal state with *external*
inputs (router location, media query), which is one of the
legitimate uses of an effect. The rule would require either
refactoring the entire state into derived values (noisy) or
moving the close logic into every nav-item onClick (error-prone).
## Testing matrix
| Category | Viewports tested |
| ---------------- | --------------------------------------------------- |
| Phones | 320, 375, 414 |
| Tablets | 768 (portrait), 1024 (landscape) |
| Laptops | 1280, 1440 |
| Desktops | 1920, 2560 |
| Orientation | Portrait and landscape at 768, 414 |
| DPR | 1× and 2× (hairline borders at 2×) |
| Reduced motion | `prefers-reduced-motion: reduce` honored |
| Keyboard | Tab order, skip-link, focus ring, drawer Escape |
## File inventory
New files:
- `src/styles/tokens.css`
- `src/styles/responsive.css`
- `src/styles/a11y.css`
- `src/hooks/useMediaQuery.ts`
- `src/hooks/useBreakpoint.ts`
- `src/hooks/useReducedMotion.ts`
- `src/hooks/useOrientation.ts`
- `src/components/a11y/SkipToContent.tsx`
- `src/components/a11y/VisuallyHidden.tsx`
- `docs/ux-responsive-strategy.md` (this document)
Modified files:
- `index.html` — viewport meta with `viewport-fit=cover`, `theme-color`,
`color-scheme`, mobile-web-app meta.
- `src/main.tsx` — import order for the four CSS layers.
- `src/App.tsx` — workspace mobile gate wrapping the IDE entrypoint.
- `src/Portal.tsx` — `<SkipToContent>` mounted once at the route root.
- `src/components/portal/PortalLayout.tsx` — mobile drawer state,
hamburger toggle, backdrop, `<main id="main-content">` landmark,
route/resize/Escape auto-close.

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,24 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0f1419" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<meta name="description" content="Solace Bank Group PLC — Treasury management, settlement orchestration, and ISO-20022 transaction builder." />
<title>Solace Bank Group PLC — Treasury Management Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -4,6 +4,6 @@ module.exports = {
testEnvironment: "node",
roots: ["<rootDir>/tests"],
testMatch: ["**/*.test.ts"],
testPathIgnorePatterns: ["/node_modules/", "/integration/", "/chaos/", "/load/", "/e2e/"],
testPathIgnorePatterns: ["/node_modules/", "/integration/", "/chaos/", "/load/"],
moduleFileExtensions: ["ts", "js", "json"],
};

View File

@@ -1,18 +0,0 @@
/** @type {import('jest').Config} */
// E2E suite — runs the Testcontainers-backed integration tests
// under tests/e2e/. Separate from the default jest.config.js because
// it requires Docker and takes significantly longer.
//
// Usage:
// RUN_E2E=1 npx jest --config=jest.e2e.config.js
//
// CI wires this into a dedicated e2e workflow step so the normal
// unit-test suite stays <5s.
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/tests/e2e"],
testMatch: ["**/*.e2e.test.ts"],
moduleFileExtensions: ["ts", "js", "json"],
testTimeout: 120_000,
};

View File

@@ -8,7 +8,6 @@
"dev": "ts-node src/index.ts",
"start": "node dist/index.js",
"test": "jest",
"test:e2e": "RUN_E2E=1 jest --config=jest.e2e.config.js",
"migrate": "ts-node src/db/migrations/index.ts"
},
"dependencies": {
@@ -28,7 +27,6 @@
},
"devDependencies": {
"@jest/globals": "^30.3.0",
"@testcontainers/postgresql": "^11.14.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^30.0.0",
@@ -36,11 +34,8 @@
"@types/pg": "^8.10.9",
"@types/supertest": "^7.2.0",
"@types/uuid": "^9.0.6",
"ganache": "^7.9.2",
"jest": "^30.3.0",
"solc": "^0.8.20",
"supertest": "^7.2.2",
"testcontainers": "^11.14.0",
"ts-jest": "^29.4.9",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"

View File

@@ -1,40 +0,0 @@
/**
* Proxmox BFF API routes — proxies browser requests to the Cloudflare
* Access protected Proxmox API using a server-side service token.
*
* These routes intentionally expose a **narrow, safelisted** surface to
* the browser — we don't want to proxy arbitrary Proxmox endpoints.
*
* Current endpoints:
* GET /api/proxmox/health — upstream reachability check
* GET /api/proxmox/cluster/status — aggregated cluster node status
*/
import type { Request, Response } from "express";
import { getClusterHealth, isProxmoxConfigured, readProxmoxEnv } from "../integrations/proxmox";
export async function proxmoxHealth(_req: Request, res: Response) {
const env = readProxmoxEnv();
if (!isProxmoxConfigured(env)) {
return res.status(503).json({
status: "unconfigured",
message:
"PROXMOX_API_URL / PROXMOX_CF_ACCESS_CLIENT_ID / PROXMOX_CF_ACCESS_CLIENT_SECRET not set on the orchestrator.",
required: ["PROXMOX_API_URL", "PROXMOX_CF_ACCESS_CLIENT_ID", "PROXMOX_CF_ACCESS_CLIENT_SECRET"],
});
}
return res.json({ status: "configured", baseUrl: env.baseUrl });
}
export async function proxmoxClusterStatus(_req: Request, res: Response) {
const env = readProxmoxEnv();
if (!isProxmoxConfigured(env)) {
return res.status(503).json({
status: "unconfigured",
online: false,
nodes: [],
message: "Proxmox BFF not configured. See GET /api/proxmox/health for required env vars.",
});
}
const health = await getClusterHealth();
return res.status(health.online ? 200 : 502).json(health);
}

View File

@@ -1,42 +1,27 @@
import { z } from "zod";
const emptyToUndefined = (value: unknown) => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
};
const optionalString = () => z.preprocess(emptyToUndefined, z.string().optional());
const optionalUrl = () => z.preprocess(emptyToUndefined, z.string().url().optional());
/**
* Environment variable validation schema
*/
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.string().transform(Number).pipe(z.number().int().positive()),
DATABASE_URL: optionalUrl(),
API_KEYS: optionalString(),
REDIS_URL: optionalUrl(),
DATABASE_URL: z.string().url().optional(),
API_KEYS: z.string().optional(),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"),
ALLOWED_IPS: optionalString(),
ALLOWED_IPS: z.string().optional(),
SESSION_SECRET: z.string().min(32),
JWT_SECRET: z.preprocess(emptyToUndefined, z.string().min(32).optional()),
AZURE_KEY_VAULT_URL: optionalUrl(),
AWS_SECRETS_MANAGER_REGION: optionalString(),
SENTRY_DSN: optionalUrl(),
JWT_SECRET: z.string().min(32).optional(),
AZURE_KEY_VAULT_URL: z.string().url().optional(),
AWS_SECRETS_MANAGER_REGION: z.string().optional(),
SENTRY_DSN: z.string().url().optional(),
// Chain-138 + NotaryRegistry wiring (arch §4.5). All optional; when
// absent the notary adapter falls back to its deterministic mock.
CHAIN_138_RPC_URL: optionalUrl(),
CHAIN_138_CHAIN_ID: z.preprocess(emptyToUndefined, z.string().regex(/^\d+$/).optional()),
NOTARY_REGISTRY_ADDRESS: z.preprocess(
emptyToUndefined,
z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(),
),
ORCHESTRATOR_PRIVATE_KEY: z.preprocess(
emptyToUndefined,
z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(),
),
CHAIN_138_RPC_URL: z.string().url().optional(),
CHAIN_138_CHAIN_ID: z.string().regex(/^\d+$/).optional(),
NOTARY_REGISTRY_ADDRESS: z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(),
ORCHESTRATOR_PRIVATE_KEY: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(),
});
/**
@@ -46,7 +31,7 @@ export const env = envSchema.parse({
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT || "8080",
DATABASE_URL: process.env.DATABASE_URL,
API_KEYS: process.env.API_KEYS || process.env.ORCHESTRATOR_API_KEYS,
API_KEYS: process.env.API_KEYS,
REDIS_URL: process.env.REDIS_URL,
LOG_LEVEL: process.env.LOG_LEVEL,
ALLOWED_IPS: process.env.ALLOWED_IPS,
@@ -71,7 +56,7 @@ export function validateEnv() {
NODE_ENV: process.env.NODE_ENV || "development",
PORT: process.env.PORT || "8080",
DATABASE_URL: process.env.DATABASE_URL,
API_KEYS: process.env.API_KEYS || process.env.ORCHESTRATOR_API_KEYS,
API_KEYS: process.env.API_KEYS,
REDIS_URL: process.env.REDIS_URL,
LOG_LEVEL: process.env.LOG_LEVEL || "info",
ALLOWED_IPS: process.env.ALLOWED_IPS,
@@ -80,10 +65,6 @@ export function validateEnv() {
AZURE_KEY_VAULT_URL: process.env.AZURE_KEY_VAULT_URL,
AWS_SECRETS_MANAGER_REGION: process.env.AWS_SECRETS_MANAGER_REGION,
SENTRY_DSN: process.env.SENTRY_DSN,
CHAIN_138_RPC_URL: process.env.CHAIN_138_RPC_URL,
CHAIN_138_CHAIN_ID: process.env.CHAIN_138_CHAIN_ID,
NOTARY_REGISTRY_ADDRESS: process.env.NOTARY_REGISTRY_ADDRESS,
ORCHESTRATOR_PRIVATE_KEY: process.env.ORCHESTRATOR_PRIVATE_KEY,
};
envSchema.parse(envWithDefaults);
console.log("✅ Environment variables validated");
@@ -98,3 +79,4 @@ export function validateEnv() {
throw error;
}
}

View File

@@ -1,159 +0,0 @@
/**
* External dependency blocker registry (EXT-* IDs).
*
* Mirrors the blocker gate in `proxmox/scripts/verify/
* check-external-dependencies.sh` so orchestrator startup logs and
* provider-switch mock-mode logs surface the **same** IDs the
* deployment pipeline already tracks. When operators see
* "[DbisCore] mock mode" they also see `blockerId: EXT-DBIS-CORE`,
* which maps 1:1 to the proxmox checker output.
*
* A blocker is considered **active** when:
* - the upstream dependency is not yet reachable / not yet built, AND
* - the orchestrator env does not point at any live instance (the
* presence of a "live URL" env var flips the blocker to resolved).
*
* Source of truth for the list: proxmox/docs/03-deployment/
* EXTERNAL_DEPENDENCY_BLOCKERS.md.
*/
export const EXT_BLOCKER_IDS = [
"EXT-DBIS-CORE",
"EXT-CC-PAYMENT-ADAPTERS",
"EXT-CC-AUDIT-LEDGER",
"EXT-CC-SHARED-EVENTS",
"EXT-CC-SHARED-SCHEMAS",
"EXT-FIN-GATEWAY",
"EXT-CHAIN138-CI-RPC",
] as const;
export type ExtBlockerId = (typeof EXT_BLOCKER_IDS)[number];
export interface ExtBlockerDetail {
id: ExtBlockerId;
title: string;
/** Env var whose presence resolves this blocker from the orchestrator's POV. */
resolvingEnvVar?: string;
/** Whether the blocker is structurally resolved independently of env. */
staticallyResolved?: boolean;
/** Short description suitable for structured logs. */
description: string;
}
export const BLOCKER_DETAILS: Record<ExtBlockerId, ExtBlockerDetail> = {
"EXT-DBIS-CORE": {
id: "EXT-DBIS-CORE",
title: "dbis_core live deployment",
resolvingEnvVar: "DBIS_CORE_URL",
description:
"DBIS Core Banking API not deployed; orchestrator falls back to deterministic mock.",
},
"EXT-CC-PAYMENT-ADAPTERS": {
id: "EXT-CC-PAYMENT-ADAPTERS",
title: "DBIS/cc-payment-adapters implementation",
description:
"Upstream repo is a template scaffold; no orchestrator client wired yet.",
},
"EXT-CC-AUDIT-LEDGER": {
id: "EXT-CC-AUDIT-LEDGER",
title: "DBIS/cc-audit-ledger implementation",
description:
"Upstream repo is a template scaffold; audit sink remains in-process events table.",
},
"EXT-CC-SHARED-EVENTS": {
id: "EXT-CC-SHARED-EVENTS",
title: "DBIS/cc-shared-events implementation",
description:
"Upstream repo is a template scaffold; orchestrator uses local eventBus schema.",
},
"EXT-CC-SHARED-SCHEMAS": {
id: "EXT-CC-SHARED-SCHEMAS",
title: "DBIS/cc-shared-schemas implementation",
description:
"Upstream repo is a template scaffold; orchestrator types are locally defined.",
},
"EXT-FIN-GATEWAY": {
id: "EXT-FIN-GATEWAY",
title: "Real FIN / Alliance Access gateway",
resolvingEnvVar: "FIN_SANDBOX_URL",
description:
"No real FIN transport; orchestrator routes dispatch through the in-process sandbox.",
},
"EXT-CHAIN138-CI-RPC": {
id: "EXT-CHAIN138-CI-RPC",
title: "Chain 138 RPC reachable from CI",
resolvingEnvVar: "CHAIN_138_RPC_URL",
description:
"Public Chain 138 RPC endpoint available; E2E and notary-chain paths can target a real chain.",
},
};
export type BlockerStatus = "active" | "resolved";
export interface BlockerStatusRecord extends ExtBlockerDetail {
status: BlockerStatus;
/** Value of the resolving env var at the time of evaluation, if any. */
resolvedVia?: string;
}
/**
* Evaluate current blocker status against `process.env` (or a
* supplied env object, for tests).
*/
export function evaluateBlockers(
env: NodeJS.ProcessEnv = process.env,
): BlockerStatusRecord[] {
return EXT_BLOCKER_IDS.map((id) => {
const detail = BLOCKER_DETAILS[id];
if (detail.staticallyResolved) {
return { ...detail, status: "resolved" };
}
if (detail.resolvingEnvVar) {
const v = env[detail.resolvingEnvVar];
if (v && v.length > 0) {
return {
...detail,
status: "resolved",
resolvedVia: detail.resolvingEnvVar,
};
}
}
return { ...detail, status: "active" };
});
}
/**
* Convenience: same as evaluateBlockers() filtered to active IDs only.
*/
export function activeBlockers(
env: NodeJS.ProcessEnv = process.env,
): ExtBlockerId[] {
return evaluateBlockers(env)
.filter((b) => b.status === "active")
.map((b) => b.id);
}
/**
* Emit a structured startup summary of external blockers using the
* supplied logger. Shape matches the proxmox checker output so
* operators can grep for the same IDs across the two systems.
*/
export function logBlockerStatusAtBoot(logger: {
info: (obj: Record<string, unknown>, msg: string) => void;
}): void {
const records = evaluateBlockers();
const active = records.filter((b) => b.status === "active").map((b) => b.id);
const resolved = records.filter((b) => b.status === "resolved").map((b) => b.id);
logger.info(
{
externalBlockers: records.map((b) => ({
id: b.id,
status: b.status,
resolvedVia: b.resolvedVia,
})),
activeCount: active.length,
resolvedCount: resolved.length,
},
`[ExternalBlockers] ${active.length} active, ${resolved.length} resolved`,
);
}

View File

@@ -1,44 +0,0 @@
import { query } from "../postgres";
/**
* Migration 004 — idempotency keys + replay protection (arch §13,
* §15: deterministic state transitions, idempotent event handling,
* resilience to duplicate messages).
*
* A caller supplies an `Idempotency-Key` header on POST requests.
* The server records `{ key, request_hash, response_body, status_code }`
* on first success and replays the cached response on subsequent
* requests with the same key. If the request body changes while the
* key is reused the server returns 422 with `key_reused_with_different_payload`.
*
* Scoped by `(method, path, key)` so the same key can safely appear
* across unrelated endpoints.
*
* Rows expire after 24h — enough to cover retry windows, short enough
* to keep the table bounded.
*/
export async function up() {
await query(
`CREATE TABLE IF NOT EXISTS idempotency_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
method VARCHAR(8) NOT NULL,
path VARCHAR(512) NOT NULL,
key VARCHAR(255) NOT NULL,
request_hash CHAR(64) NOT NULL,
status_code INTEGER NOT NULL,
response_body JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '24 hours'),
UNIQUE (method, path, key)
)`,
);
await query(
`CREATE INDEX IF NOT EXISTS idx_idempotency_expires_at
ON idempotency_keys(expires_at)`,
);
}
export async function down() {
await query("DROP TABLE IF EXISTS idempotency_keys CASCADE");
}

View File

@@ -1,7 +1,6 @@
import { up as up001 } from "./001_initial_schema";
import { up as up002 } from "./002_transaction_state";
import { up as up003 } from "./003_events";
import { up as up004 } from "./004_idempotency_keys";
/**
* Run all migrations
@@ -11,7 +10,6 @@ export async function runMigration() {
await up001();
await up002();
await up003();
await up004();
console.log("All migrations completed");
} catch (error) {
console.error("Migration failed:", error);

View File

@@ -2,7 +2,6 @@ import "dotenv/config";
import express from "express";
import cors from "cors";
import { validateEnv } from "./config/env";
import { logBlockerStatusAtBoot } from "./config/externalBlockers";
import {
apiLimiter,
securityHeaders,
@@ -10,7 +9,6 @@ import {
requestId,
apiKeyAuth,
auditLog,
idempotencyMiddleware,
} from "./middleware";
import { requestTimeout } from "./middleware/timeout";
import { logger } from "./logging/logger";
@@ -24,11 +22,6 @@ import { runMigration } from "./db/migrations";
// Validate environment on startup
validateEnv();
// Surface the current EXT-* external-dependency blocker status so
// orchestrator startup logs match the proxmox deployment checker
// (proxmox/scripts/verify/check-external-dependencies.sh) 1:1.
logBlockerStatusAtBoot(logger);
const app = express();
const PORT = process.env.PORT || 8080;
@@ -70,28 +63,16 @@ app.get("/health", async (req, res) => {
const health = await healthCheck();
res.status(health.status === "healthy" ? 200 : 503).json(health);
});
app.get("/api/health", async (req, res) => {
const health = await healthCheck();
res.status(health.status === "healthy" ? 200 : 503).json(health);
});
app.get("/ready", async (req, res) => {
const ready = await readinessCheck();
res.status(ready ? 200 : 503).json({ ready });
});
app.get("/api/ready", async (req, res) => {
const ready = await readinessCheck();
res.status(ready ? 200 : 503).json({ ready });
});
app.get("/live", async (req, res) => {
const alive = await livenessCheck();
res.status(alive ? 200 : 503).json({ alive });
});
app.get("/api/live", async (req, res) => {
const alive = await livenessCheck();
res.status(alive ? 200 : 503).json({ alive });
});
// Metrics endpoint
app.get("/metrics", async (req, res) => {
@@ -99,18 +80,13 @@ app.get("/metrics", async (req, res) => {
const metrics = await getMetrics();
res.send(metrics);
});
app.get("/api/metrics", async (req, res) => {
res.setHeader("Content-Type", register.contentType);
const metrics = await getMetrics();
res.send(metrics);
});
// API routes with rate limiting
app.use("/api", apiLimiter);
// Plan management endpoints
app.get("/api/plans", listPlansEndpoint);
app.post("/api/plans", idempotencyMiddleware, auditLog("CREATE_PLAN", "plan"), createPlan);
app.post("/api/plans", auditLog("CREATE_PLAN", "plan"), createPlan);
app.get("/api/plans/:planId", getPlan);
app.get("/api/plans/:planId/state", getPlanState);
app.get("/api/plans/:planId/events", getPlanEvents);
@@ -121,33 +97,13 @@ app.post("/api/plans/:planId/validate", validatePlanEndpoint);
// Execution endpoints
import { executePlan, getExecutionStatus, abortExecution } from "./api/execution";
import { registerWebhook } from "./api/webhooks";
app.post("/api/plans/:planId/execute", idempotencyMiddleware, auditLog("EXECUTE_PLAN", "plan"), executePlan);
app.post("/api/plans/:planId/execute", auditLog("EXECUTE_PLAN", "plan"), executePlan);
app.get("/api/plans/:planId/status", getExecutionStatus);
app.post("/api/plans/:planId/abort", auditLog("ABORT_PLAN", "plan"), abortExecution);
app.post("/api/webhooks", registerWebhook);
// Proxmox BFF — forwards browser requests to the CF-Access protected
// Proxmox API using a server-side service token. See
// orchestrator/src/integrations/proxmox.ts for required env.
import { proxmoxHealth, proxmoxClusterStatus } from "./api/proxmox";
app.get("/api/proxmox/health", proxmoxHealth);
app.get("/api/proxmox/cluster/status", proxmoxClusterStatus);
app.get("/api/plans/:planId/status/stream", streamPlanStatus);
// FIN-link sandbox transport (gap-analysis v2 §7.1 / §10.6).
// Mounted only when FIN_SANDBOX_ENABLED=true so production builds
// don't expose the in-memory fake. Intended for dev + E2E only.
if (process.env.FIN_SANDBOX_ENABLED === "true") {
import("./services/finLink/sandbox").then(({ buildSandboxRouter, startAutoProgress }) => {
app.use("/fin-sandbox", buildSandboxRouter());
if (process.env.FIN_SANDBOX_AUTO_PROGRESS !== "false") {
startAutoProgress(Number(process.env.FIN_SANDBOX_TICK_MS || 2000));
}
logger.info({ route: "/fin-sandbox" }, "FIN-link sandbox mounted");
});
}
// Error handling middleware
import { errorHandler } from "./services/errorHandler";
import { initRedis } from "./services/redis";
@@ -190,3 +146,4 @@ async function start() {
}
start();

View File

@@ -1,111 +0,0 @@
/**
* Proxmox API BFF client.
*
* Proxmox's API (https://proxmox-api.d-bis.org) sits behind Cloudflare
* Access. Browsers cannot carry CF-Access JWTs without completing an SSO
* flow, so the portal calls our Express orchestrator and we forward
* requests with a Cloudflare Access Service Token.
*
* Required env:
* PROXMOX_API_URL - upstream base URL (e.g. https://proxmox-api.d-bis.org)
* PROXMOX_CF_ACCESS_CLIENT_ID - CF Access service token ID
* PROXMOX_CF_ACCESS_CLIENT_SECRET - CF Access service token secret
*
* When any of these are missing, the client returns null/empty responses
* and the HTTP layer surfaces a 503 with an actionable body so the portal
* knows to stay in its mocked state.
*/
import { logger } from "../logging/logger";
export interface ProxmoxEnv {
baseUrl: string | undefined;
clientId: string | undefined;
clientSecret: string | undefined;
}
export function readProxmoxEnv(): ProxmoxEnv {
return {
baseUrl: process.env.PROXMOX_API_URL,
clientId: process.env.PROXMOX_CF_ACCESS_CLIENT_ID,
clientSecret: process.env.PROXMOX_CF_ACCESS_CLIENT_SECRET,
};
}
export function isProxmoxConfigured(env: ProxmoxEnv = readProxmoxEnv()): boolean {
return !!(env.baseUrl && env.clientId && env.clientSecret);
}
/**
* Forwards a GET request to Proxmox through the CF Access service token.
* Returns the upstream JSON and status verbatim. Throws on network failure.
*/
export async function proxmoxForwardGet(
path: string,
env: ProxmoxEnv = readProxmoxEnv(),
): Promise<{ status: number; body: unknown }> {
if (!isProxmoxConfigured(env)) {
throw new Error("PROXMOX_NOT_CONFIGURED");
}
const url = new URL(path.startsWith("/") ? path : `/${path}`, env.baseUrl).toString();
const res = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
// Cloudflare Access service token headers.
"CF-Access-Client-Id": env.clientId!,
"CF-Access-Client-Secret": env.clientSecret!,
},
});
const contentType = res.headers.get("content-type") ?? "";
const body = contentType.includes("application/json")
? await res.json().catch(() => null)
: await res.text();
return { status: res.status, body };
}
export interface ClusterHealth {
source: "proxmox";
online: boolean;
nodes: Array<{ name: string; status: string; uptime: number | null }>;
lastChecked: string;
}
/**
* Convenience wrapper — returns an aggregated cluster health summary from
* the Proxmox `/api2/json/cluster/status` endpoint. Surfaces a degraded
* state when configuration is missing rather than throwing so callers can
* render a consistent payload.
*/
export async function getClusterHealth(): Promise<ClusterHealth> {
const env = readProxmoxEnv();
if (!isProxmoxConfigured(env)) {
return {
source: "proxmox",
online: false,
nodes: [],
lastChecked: new Date().toISOString(),
};
}
try {
const { status, body } = await proxmoxForwardGet("/api2/json/cluster/status", env);
if (status >= 400 || !body || typeof body !== "object") {
logger.warn({ status, body }, "proxmox cluster status non-2xx");
return { source: "proxmox", online: false, nodes: [], lastChecked: new Date().toISOString() };
}
const data = (body as { data?: unknown }).data;
if (!Array.isArray(data)) {
return { source: "proxmox", online: true, nodes: [], lastChecked: new Date().toISOString() };
}
const nodes = data
.filter((n: { type?: string }) => n.type === "node")
.map((n: { name?: string; status?: string; uptime?: number }) => ({
name: n.name ?? "unknown",
status: n.status ?? "unknown",
uptime: typeof n.uptime === "number" ? n.uptime : null,
}));
return { source: "proxmox", online: true, nodes, lastChecked: new Date().toISOString() };
} catch (err) {
logger.error({ err }, "proxmox cluster status fetch failed");
return { source: "proxmox", online: false, nodes: [], lastChecked: new Date().toISOString() };
}
}

View File

@@ -1,120 +0,0 @@
/**
* Idempotency-Key middleware (arch §13 security requirements,
* §15 non-functional: idempotent event handling, replay protection).
*
* Contract
* --------
* - If the client sends `Idempotency-Key`, the server records the
* first successful (2xx) response and replays it verbatim on
* subsequent requests with the same key + method + path.
* - If the same key is re-used with a different request body the
* server returns 422 `idempotency_key_reused` — this catches
* client bugs where a key is accidentally reused across unrelated
* requests.
* - Keys are scoped by `(method, path, key)` and expire after 24h.
* - Responses are captured by shimming `res.json()` — no deep
* integration with route handlers required.
* - Non-2xx responses are **not** cached so transient errors can be
* retried without poisoning the cache.
*
* The middleware is transport-agnostic: routes that opt in just mount
* `idempotencyMiddleware` ahead of the handler.
*/
import type { NextFunction, Request, Response } from "express";
import { createHash } from "crypto";
import { query } from "../db/postgres";
import { logger } from "../logging/logger";
export const IDEMPOTENCY_HEADER = "idempotency-key";
const KEY_PATTERN = /^[A-Za-z0-9_\-:.]{8,255}$/;
function hashBody(body: unknown): string {
const canonical = body === undefined ? "" : JSON.stringify(body);
return createHash("sha256").update(canonical).digest("hex");
}
interface CachedRow {
request_hash: string;
status_code: number;
response_body: unknown;
}
export async function idempotencyMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const rawKey = req.header(IDEMPOTENCY_HEADER);
if (!rawKey) {
next();
return;
}
if (!KEY_PATTERN.test(rawKey)) {
res.status(400).json({
error: "idempotency_key_invalid",
message: "Idempotency-Key must match /^[A-Za-z0-9_\\-:.]{8,255}$/",
});
return;
}
const key = rawKey;
const method = req.method;
const path = req.baseUrl + req.path;
const requestHash = hashBody(req.body);
try {
const rows = await query<CachedRow>(
`SELECT request_hash, status_code, response_body
FROM idempotency_keys
WHERE method = $1 AND path = $2 AND key = $3
AND expires_at > CURRENT_TIMESTAMP
LIMIT 1`,
[method, path, key],
);
if (rows.length > 0) {
const cached = rows[0];
if (cached.request_hash !== requestHash) {
res.status(422).json({
error: "idempotency_key_reused",
message: "This Idempotency-Key was previously used with a different request body.",
});
return;
}
res.setHeader("Idempotent-Replayed", "true");
res.status(cached.status_code).json(cached.response_body);
return;
}
} catch (err) {
// Fail open: if the lookup fails we still process the request so
// the caller never sees a hard 500 because the dedup table is
// unavailable. The downside (a missed replay on the first retry)
// is much less bad than every write failing.
logger.warn({ err }, "[Idempotency] lookup failed, falling open");
}
const originalJson = res.json.bind(res);
res.json = (body: unknown): Response => {
const statusCode = res.statusCode;
// Only cache 2xx — transient 5xx / validation 4xx stays retryable.
if (statusCode >= 200 && statusCode < 300) {
// Fire-and-forget; response is already known and can be sent.
query(
`INSERT INTO idempotency_keys
(method, path, key, request_hash, status_code, response_body)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
ON CONFLICT (method, path, key) DO NOTHING`,
[method, path, key, requestHash, statusCode, JSON.stringify(body)],
).catch((err) => {
logger.warn({ err, key, method, path }, "[Idempotency] write failed");
});
}
return originalJson(body);
};
next();
}
/** exposed for tests */
export const __testing = { hashBody, KEY_PATTERN };

View File

@@ -5,5 +5,4 @@ export { validate, sanitizeInput } from "./validation";
export { ipWhitelist, getClientIP } from "./ipWhitelist";
export { auditLog } from "./auditLog";
export { sessionManager } from "./session";
export { idempotencyMiddleware, IDEMPOTENCY_HEADER } from "./idempotency";

View File

@@ -1,44 +1,20 @@
import type { Plan } from "../types/plan";
import { generatePacs008 } from "./iso20022";
import { logger } from "../logging/logger";
/**
* Bank-instruction client — two-phase-commit adapter for the payment
* leg (arch §4.3 Payment Messaging / Settlement Layer).
*
* Until `d-bis/dbis_core` is reachable as a live API, every call here
* is a deterministic mock. That corresponds to blocker EXT-DBIS-CORE
* in proxmox/docs/03-deployment/EXTERNAL_DEPENDENCY_BLOCKERS.md and
* flips to real once DBIS_CORE_URL is set (see services/dbisCore/).
*/
const BLOCKER_ID = "EXT-DBIS-CORE";
function bankMode(): "live" | "mock" {
return process.env.DBIS_CORE_URL ? "live" : "mock";
}
/**
* Prepare bank instruction (2PC prepare phase)
* Sends provisional ISO-20022 message
*/
export async function prepareBankInstruction(plan: Plan): Promise<boolean> {
const mode = bankMode();
logger.info(
{
planId: plan.plan_id,
mode,
...(mode === "mock" ? { blockerId: BLOCKER_ID } : {}),
},
"[Bank] prepareBankInstruction()",
);
console.log(`[Bank] Preparing instruction for plan ${plan.plan_id}`);
// Mock: In real implementation, this would:
// 1. Generate provisional ISO-20022 message (pacs.008 with conditional settlement)
// 2. Send to bank connector
// 3. Receive provisional acceptance
await new Promise((resolve) => setTimeout(resolve, 100));
return true;
}
@@ -51,39 +27,30 @@ export async function commitBankInstruction(plan: Plan): Promise<{
isoMessageId?: string;
error?: string;
}> {
const mode = bankMode();
logger.info(
{
planId: plan.plan_id,
mode,
...(mode === "mock" ? { blockerId: BLOCKER_ID } : {}),
},
"[Bank] commitBankInstruction()",
);
console.log(`[Bank] Committing instruction for plan ${plan.plan_id}`);
try {
// Generate final ISO-20022 message
const isoMessage = await generatePacs008(plan);
// Mock: In real implementation, this would:
// 1. Send ISO message to bank connector
// 2. Receive confirmation and message ID
// 3. Store message ID for audit trail
const isoMessageId = `MSG-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const isoMessageId = `MSG-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Simulate processing delay
await new Promise((resolve) => setTimeout(resolve, 300));
return {
success: true,
isoMessageId,
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
} catch (error: any) {
return {
success: false,
error,
error: error.message,
};
}
}
@@ -93,20 +60,13 @@ export async function commitBankInstruction(plan: Plan): Promise<{
* Cancels provisional instruction
*/
export async function abortBankInstruction(planId: string): Promise<void> {
const mode = bankMode();
logger.info(
{
planId,
mode,
...(mode === "mock" ? { blockerId: BLOCKER_ID } : {}),
},
"[Bank] abortBankInstruction()",
);
console.log(`[Bank] Aborting instruction for plan ${planId}`);
// Mock: In real implementation, this would:
// 1. Generate cancellation message (camt.056)
// 2. Send to bank connector
// 3. Confirm cancellation
await new Promise((resolve) => setTimeout(resolve, 100));
}

View File

@@ -1,183 +0,0 @@
/**
* Loader for the `DBIS/cc-compliance-controls` controls matrix.
*
* `cc-compliance-controls` ships a v0 matrix at
* `controls/matrix/v0.yaml`. When `CC_CONTROLS_MATRIX_URL` is set the
* loader fetches that remote YAML; otherwise it returns an embedded
* snapshot so the orchestrator always has a usable matrix to assert
* against in validation/obligation flows without a network hop.
*
* The embedded snapshot is a faithful copy of the upstream v0 matrix
* at recon time — if upstream evolves, re-sync by fetching and
* replacing the `EMBEDDED_V0_MATRIX` literal.
*/
import { logger } from "../../logging/logger";
import type { CcControlsMatrix } from "./types";
export interface CcControlsConfig {
url?: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
}
/**
* Embedded v0 matrix — kept small and hand-typed rather than parsed
* from YAML so the orchestrator doesn't drag in a YAML runtime.
*/
const EMBEDDED_V0_MATRIX: CcControlsMatrix = {
version: 0,
source: "embedded",
domains: [
{
id: "identity_proofing",
controls: [
{
id: "IDP-001",
title: "Identity enrollment recorded in audit ledger",
evidenceType: "audit_event",
ownerTeam: "TBD",
frequency: "continuous",
},
],
},
{
id: "payment_issuance",
controls: [
{
id: "PAY-001",
title: "No production PAN in non-production",
evidenceType: "config_scan",
ownerTeam: "TBD",
frequency: "per_release",
},
],
},
{
id: "audit_non_repudiation",
controls: [
{
id: "AUD-001",
title: "Credential state change only via workflow + immutable event",
evidenceType: "architecture_review",
ownerTeam: "TBD",
frequency: "quarterly",
},
],
},
{
id: "registry_verticals",
controls: [
{
id: "REG-001",
title: "Judicial registry data classified high sensitivity; tenant-scoped APIs only",
evidenceType: "policy_review",
ownerTeam: "TBD",
frequency: "quarterly",
},
],
},
],
};
function loadConfigFromEnv(): CcControlsConfig {
return {
url: process.env.CC_CONTROLS_MATRIX_URL,
timeoutMs: process.env.CC_CONTROLS_MATRIX_TIMEOUT_MS
? parseInt(process.env.CC_CONTROLS_MATRIX_TIMEOUT_MS, 10)
: 10_000,
};
}
/**
* Minimal JSON-or-YAML-ish adapter: upstream ships YAML today but
* could add a JSON endpoint. This loader only accepts `application/
* json` responses — if the endpoint is pure YAML, serve it via a thin
* JSON-convert proxy or extend this loader.
*/
export async function loadControlsMatrix(
cfg: CcControlsConfig = loadConfigFromEnv(),
): Promise<CcControlsMatrix> {
if (!cfg.url) {
logger.info(
{ source: "embedded" },
"[CcControls] controls matrix (no CC_CONTROLS_MATRIX_URL — embedded v0)",
);
return EMBEDDED_V0_MATRIX;
}
const fetchImpl = cfg.fetchImpl ?? fetch;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 10_000);
try {
const resp = await fetchImpl(cfg.url, {
method: "GET",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!resp.ok) {
throw new Error(
`cc-controls matrix GET failed: HTTP ${resp.status}`,
);
}
const body = (await resp.json()) as unknown;
const parsed = normaliseMatrix(body);
logger.info(
{ source: "remote", url: cfg.url, version: parsed.version },
"[CcControls] controls matrix (remote)",
);
return parsed;
} finally {
clearTimeout(timer);
}
}
function normaliseMatrix(raw: unknown): CcControlsMatrix {
if (typeof raw !== "object" || raw === null) {
throw new Error("cc-controls matrix: response is not an object");
}
const r = raw as Record<string, unknown>;
const version = typeof r.version === "number" ? r.version : 0;
const domains = Array.isArray(r.domains) ? r.domains : [];
return {
version,
source: "remote",
domains: domains.map((d) => normaliseDomain(d)),
};
}
function normaliseDomain(raw: unknown): CcControlsMatrix["domains"][number] {
const r = (raw ?? {}) as Record<string, unknown>;
const controls = Array.isArray(r.controls) ? r.controls : [];
return {
id: String(r.id ?? ""),
controls: controls.map((c) => normaliseControl(c)),
};
}
function normaliseControl(raw: unknown): CcControlsMatrix["domains"][number]["controls"][number] {
const r = (raw ?? {}) as Record<string, unknown>;
return {
id: String(r.id ?? ""),
title: String(r.title ?? ""),
evidenceType: String(r.evidence_type ?? r.evidenceType ?? ""),
ownerTeam: String(r.owner_team ?? r.ownerTeam ?? "TBD"),
frequency: String(r.frequency ?? ""),
};
}
/**
* Convenience helper — resolve a control by id across all domains.
* Used by evaluator flows that need to attach control evidence to a
* transition.
*/
export function findControl(
matrix: CcControlsMatrix,
controlId: string,
): CcControlsMatrix["domains"][number]["controls"][number] | undefined {
for (const d of matrix.domains) {
for (const c of d.controls) {
if (c.id === controlId) return c;
}
}
return undefined;
}

View File

@@ -1,155 +0,0 @@
/**
* HTTP client adapter for `DBIS/cc-identity-core`.
*
* Provider-switched: when `CC_IDENTITY_URL` is set the client makes
* real HTTP calls to the upstream Complete Credential identity
* service; otherwise every method returns a deterministic mock so
* unit tests, local dev, and CI still work.
*
* Upstream surface (openapi.yaml + src/server.mjs at recon time):
* GET /health
* GET /ready
* POST /v1/subjects
*
* Extend as additional endpoints ship upstream.
*/
import { randomUUID } from "crypto";
import { logger } from "../../logging/logger";
import type {
CcHealthStatus,
CcSubject,
CcSubjectCreate,
} from "./types";
export interface CcIdentityConfig {
baseUrl?: string;
apiKey?: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
}
export interface CcIdentityClient {
mode: "live" | "mock";
health(): Promise<CcHealthStatus>;
ready(): Promise<CcHealthStatus>;
createSubject(req: CcSubjectCreate, correlationId?: string): Promise<CcSubject>;
}
function loadConfigFromEnv(): CcIdentityConfig {
return {
baseUrl: process.env.CC_IDENTITY_URL,
apiKey: process.env.CC_IDENTITY_API_KEY,
timeoutMs: process.env.CC_IDENTITY_TIMEOUT_MS
? parseInt(process.env.CC_IDENTITY_TIMEOUT_MS, 10)
: 10_000,
};
}
class HttpCcIdentityClient implements CcIdentityClient {
readonly mode = "live" as const;
private readonly baseUrl: string;
private readonly apiKey?: string;
private readonly timeoutMs: number;
private readonly fetchImpl: typeof fetch;
constructor(
cfg: Required<Pick<CcIdentityConfig, "baseUrl">> & CcIdentityConfig,
) {
this.baseUrl = cfg.baseUrl.replace(/\/+$/, "");
this.apiKey = cfg.apiKey;
this.timeoutMs = cfg.timeoutMs ?? 10_000;
this.fetchImpl = cfg.fetchImpl ?? fetch;
}
private async request<T>(
method: "GET" | "POST",
path: string,
body?: unknown,
correlationId?: string,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = { Accept: "application/json" };
if (body !== undefined) headers["Content-Type"] = "application/json";
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
if (correlationId) headers["X-Correlation-Id"] = correlationId;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const resp = await this.fetchImpl(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(
`cc-identity ${method} ${path} failed: HTTP ${resp.status} ${text.slice(0, 200)}`,
);
}
return (await resp.json()) as T;
} finally {
clearTimeout(timer);
}
}
health(): Promise<CcHealthStatus> {
return this.request<CcHealthStatus>("GET", "/health");
}
ready(): Promise<CcHealthStatus> {
return this.request<CcHealthStatus>("GET", "/ready");
}
createSubject(
req: CcSubjectCreate,
correlationId?: string,
): Promise<CcSubject> {
return this.request<CcSubject>(
"POST",
"/v1/subjects",
req,
correlationId ?? randomUUID(),
);
}
}
class MockCcIdentityClient implements CcIdentityClient {
readonly mode = "mock" as const;
async health(): Promise<CcHealthStatus> {
return { status: "ok", service: "cc-identity-core" };
}
async ready(): Promise<CcHealthStatus> {
return { status: "ok", service: "cc-identity-core", persistence: false };
}
async createSubject(req: CcSubjectCreate): Promise<CcSubject> {
return {
subjectId: randomUUID(),
tenantId: req.tenantId ?? "tenant-demo",
entityId: req.entityId ?? "entity-demo",
createdAt: new Date().toISOString(),
};
}
}
export function createCcIdentityClient(
cfg: CcIdentityConfig = loadConfigFromEnv(),
): CcIdentityClient {
if (cfg.baseUrl) {
logger.info(
{ baseUrl: cfg.baseUrl, mode: "live" },
"[CcIdentity] HTTP client",
);
return new HttpCcIdentityClient({ ...cfg, baseUrl: cfg.baseUrl });
}
logger.info(
{ mode: "mock" },
"[CcIdentity] HTTP client (no CC_IDENTITY_URL — mock mode; upstream cc-identity-core ships code but not yet deployed)",
);
return new MockCcIdentityClient();
}

View File

@@ -1,28 +0,0 @@
/**
* Public surface for the DBIS Complete Credential (cc-*) adapters.
*
* Covers the upstream bounded-context repos the orchestrator needs:
* - cc-identity-core → identityClient (HTTP, provider-switched)
* - cc-compliance-controls → controlsMatrix (embedded v0 with
* optional remote JSON override)
*
* cc-payment-adapters / cc-audit-ledger / cc-shared-events are still
* template scaffolds upstream at recon time; when those services
* ship, add sibling clients here following the same pattern.
*/
export * from "./types";
export {
createCcIdentityClient,
} from "./identityClient";
export type {
CcIdentityClient,
CcIdentityConfig,
} from "./identityClient";
export {
loadControlsMatrix,
findControl,
} from "./controlsMatrix";
export type {
CcControlsConfig,
} from "./controlsMatrix";

View File

@@ -1,49 +0,0 @@
/**
* Types shared across the Complete Credential (DBIS cc-*) adapters.
*
* Shapes mirror the relevant upstream repos:
* - cc-identity-core (openapi/openapi.yaml + src/server.mjs)
* - cc-compliance-controls (controls/matrix/v0.yaml)
*
* Only the fields the orchestrator actually consumes are typed —
* extend as needed when more of the CC surface is wired.
*/
export interface CcHealthStatus {
status: "ok" | "unready";
service: string;
persistence?: boolean;
error?: string;
}
export interface CcSubjectCreate {
tenantId?: string;
entityId?: string;
metadata?: Record<string, string | number | boolean>;
}
export interface CcSubject {
subjectId: string;
tenantId: string;
entityId: string;
createdAt: string;
}
export interface CcControl {
id: string;
title: string;
evidenceType: string;
ownerTeam: string;
frequency: string;
}
export interface CcControlDomain {
id: string;
controls: CcControl[];
}
export interface CcControlsMatrix {
version: number;
source: "embedded" | "remote";
domains: CcControlDomain[];
}

View File

@@ -1,218 +0,0 @@
/**
* HTTP client adapter for `d-bis/dbis_core`.
*
* Provider-switched: when `DBIS_CORE_URL` is set the client makes real
* HTTP calls to the upstream DBIS Core Banking API; otherwise every
* method returns a deterministic mock response so unit tests, local
* dev, and CI still work.
*
* This is intentionally minimal — only the endpoints the orchestrator
* actually calls from its settlement / obligation / compliance paths.
* Extend the client surface as new orchestrator capabilities need more
* of the dbis_core API.
*/
import { logger } from "../../logging/logger";
import type {
AccountBalance,
AriDecisionRequest,
AriDecisionResponse,
AtomicSettleRequest,
AtomicSettleResponse,
Pacs008DispatchRequest,
Pacs008DispatchResponse,
RouteRequest,
RouteResponse,
SettlementStatus,
} from "./types";
export interface DbisCoreConfig {
baseUrl?: string;
apiKey?: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
}
export interface DbisCoreClient {
mode: "live" | "mock";
getAccountBalance(accountId: string): Promise<AccountBalance>;
findSettlementRoute(req: RouteRequest): Promise<RouteResponse>;
atomicSettle(req: AtomicSettleRequest): Promise<AtomicSettleResponse>;
getSettlementStatus(settlementId: string): Promise<SettlementStatus>;
requestAriDecision(req: AriDecisionRequest): Promise<AriDecisionResponse>;
dispatchPacs008(req: Pacs008DispatchRequest): Promise<Pacs008DispatchResponse>;
}
function loadConfigFromEnv(): DbisCoreConfig {
return {
baseUrl: process.env.DBIS_CORE_URL,
apiKey: process.env.DBIS_CORE_API_KEY,
timeoutMs: process.env.DBIS_CORE_TIMEOUT_MS
? parseInt(process.env.DBIS_CORE_TIMEOUT_MS, 10)
: 10_000,
};
}
class HttpDbisCoreClient implements DbisCoreClient {
readonly mode = "live" as const;
private readonly baseUrl: string;
private readonly apiKey?: string;
private readonly timeoutMs: number;
private readonly fetchImpl: typeof fetch;
constructor(cfg: Required<Pick<DbisCoreConfig, "baseUrl">> & DbisCoreConfig) {
this.baseUrl = cfg.baseUrl.replace(/\/+$/, "");
this.apiKey = cfg.apiKey;
this.timeoutMs = cfg.timeoutMs ?? 10_000;
this.fetchImpl = cfg.fetchImpl ?? fetch;
}
private async request<T>(
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
Accept: "application/json",
};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const resp = await this.fetchImpl(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(
`dbis_core ${method} ${path} failed: HTTP ${resp.status} ${text.slice(0, 200)}`,
);
}
return (await resp.json()) as T;
} finally {
clearTimeout(timer);
}
}
getAccountBalance(accountId: string): Promise<AccountBalance> {
return this.request<AccountBalance>(
"GET",
`/api/accounts/${encodeURIComponent(accountId)}/balance`,
);
}
findSettlementRoute(req: RouteRequest): Promise<RouteResponse> {
return this.request<RouteResponse>("POST", "/api/isn/route", req);
}
atomicSettle(req: AtomicSettleRequest): Promise<AtomicSettleResponse> {
return this.request<AtomicSettleResponse>("POST", "/api/isn/atomic", req);
}
getSettlementStatus(settlementId: string): Promise<SettlementStatus> {
return this.request<SettlementStatus>(
"GET",
`/api/isn/settlements/${encodeURIComponent(settlementId)}`,
);
}
requestAriDecision(req: AriDecisionRequest): Promise<AriDecisionResponse> {
return this.request<AriDecisionResponse>("POST", "/api/ari/decision", req);
}
dispatchPacs008(req: Pacs008DispatchRequest): Promise<Pacs008DispatchResponse> {
return this.request<Pacs008DispatchResponse>(
"POST",
"/api/v1/gpn/message/pacs008",
req,
);
}
}
class MockDbisCoreClient implements DbisCoreClient {
readonly mode = "mock" as const;
async getAccountBalance(accountId: string): Promise<AccountBalance> {
return {
accountId,
currency: "USD",
available: "1000000.00",
held: "0.00",
asOf: new Date().toISOString(),
};
}
async findSettlementRoute(req: RouteRequest): Promise<RouteResponse> {
return {
routeId: `mock-route-${req.sourceBankId}-${req.destinationBankId}`,
hops: [
{ bankId: req.sourceBankId, latencyMs: 20, feeBps: 0 },
{ bankId: req.destinationBankId, latencyMs: 40, feeBps: 5 },
],
estimatedLatencyMs: 60,
estimatedFeeBps: 5,
};
}
async atomicSettle(req: AtomicSettleRequest): Promise<AtomicSettleResponse> {
return {
settlementId: `mock-stlm-${req.reference}`,
status: "settled",
completedAt: new Date().toISOString(),
};
}
async getSettlementStatus(settlementId: string): Promise<SettlementStatus> {
return {
settlementId,
status: "settled",
legs: [
{ legId: `${settlementId}-leg1`, bankId: "mock-src", status: "confirmed" },
{ legId: `${settlementId}-leg2`, bankId: "mock-dst", status: "confirmed" },
],
lastUpdated: new Date().toISOString(),
};
}
async requestAriDecision(req: AriDecisionRequest): Promise<AriDecisionResponse> {
return {
txId: req.txId,
outcome: "allow",
riskScore: 0.1,
reasons: ["mock: default allow"],
evaluatedAt: new Date().toISOString(),
};
}
async dispatchPacs008(req: Pacs008DispatchRequest): Promise<Pacs008DispatchResponse> {
return {
messageId: req.messageId,
status: "accepted",
acknowledgmentRef: `mock-ack-${req.messageId}`,
};
}
}
/**
* Factory. Call once per process (or per test run) to get a client
* wired to whichever backend the env selects.
*/
export function createDbisCoreClient(
cfg: DbisCoreConfig = loadConfigFromEnv(),
): DbisCoreClient {
if (cfg.baseUrl) {
logger.info({ baseUrl: cfg.baseUrl, mode: "live" }, "[DbisCore] HTTP client");
return new HttpDbisCoreClient({ ...cfg, baseUrl: cfg.baseUrl });
}
logger.info(
{ mode: "mock", blockerId: "EXT-DBIS-CORE" },
"[DbisCore] HTTP client (no DBIS_CORE_URL — mock mode; blocker EXT-DBIS-CORE active)",
);
return new MockDbisCoreClient();
}

View File

@@ -1,9 +0,0 @@
/**
* Public surface for the dbis_core client adapter.
* See ./client.ts for implementation and ./types.ts for the shared
* request/response shapes.
*/
export * from "./types";
export { createDbisCoreClient } from "./client";
export type { DbisCoreClient, DbisCoreConfig } from "./client";

View File

@@ -1,106 +0,0 @@
/**
* Canonical request/response shapes for the subset of `d-bis/dbis_core`
* endpoints the orchestrator actually calls. Kept small and focused —
* this is a client adapter, not a mirror of the upstream service.
*
* Upstream endpoint references (from dbis_core/src/integration/api-
* gateway/app.ts mount points):
*
* GET /api/accounts/:accountId/balance
* POST /api/isn/route
* POST /api/isn/atomic
* POST /api/ari/decision
* POST /api/v1/gpn/message/pacs008
* GET /api/isn/settlements/:settlementId
*/
export interface AccountBalance {
accountId: string;
currency: string;
available: string;
held: string;
asOf: string;
}
export interface RouteRequest {
sourceBankId: string;
destinationBankId: string;
amount: string;
currencyCode: string;
}
export interface SettlementHop {
bankId: string;
latencyMs: number;
feeBps: number;
}
export interface RouteResponse {
routeId: string;
hops: SettlementHop[];
estimatedLatencyMs: number;
estimatedFeeBps: number;
}
export interface AtomicSettleRequest {
routeId: string;
sourceAccountId: string;
destinationAccountId: string;
amount: string;
currencyCode: string;
reference: string;
}
export interface AtomicSettleResponse {
settlementId: string;
status: "accepted" | "settled" | "rejected";
completedAt?: string;
rejectionReason?: string;
}
export interface AriDecisionRequest {
txId: string;
amount: string;
currencyCode: string;
creator: string;
counterparty?: string;
metadata?: Record<string, string | number | boolean>;
}
export type AriOutcome = "allow" | "deny" | "review";
export interface AriDecisionResponse {
txId: string;
outcome: AriOutcome;
riskScore: number;
reasons: string[];
evaluatedAt: string;
}
export interface Pacs008DispatchRequest {
messageId: string;
creationDateTime: string;
debtor: { name: string; bic: string; account: string };
creditor: { name: string; bic: string; account: string };
amount: string;
currencyCode: string;
remittanceInfo?: string;
}
export interface Pacs008DispatchResponse {
messageId: string;
status: "accepted" | "rejected";
acknowledgmentRef?: string;
rejectionReason?: string;
}
export interface SettlementStatus {
settlementId: string;
status: "pending" | "routing" | "executing" | "settled" | "failed" | "reversed";
legs: {
legId: string;
bankId: string;
status: "pending" | "dispatched" | "confirmed" | "failed";
}[];
lastUpdated: string;
}

View File

@@ -1,94 +0,0 @@
/**
* FIN-link client (gap-analysis v2 §7.1 / §10.6).
*
* Thin wrapper around the outbound dispatch API. In dev / E2E it
* talks to the sandbox server mounted at FIN_SANDBOX_URL. In
* production it should talk to a real FIN / Alliance Access gateway
* that exposes the same minimal surface.
*
* The SWIFT message generators live in `services/swift/`; this
* client is the transport hop that PR E was missing.
*/
import type {
DispatchRequest,
DispatchResponse,
FinMessage,
} from "./sandbox";
export interface FinLinkClient {
dispatch(req: DispatchRequest): Promise<DispatchResponse>;
getMessage(reference: string): Promise<FinMessage | null>;
}
export function createHttpFinLinkClient(baseUrl: string): FinLinkClient {
const base = baseUrl.replace(/\/$/, "");
return {
async dispatch(req) {
const resp = await fetch(`${base}/dispatch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(req),
});
if (!resp.ok) {
throw new Error(`fin dispatch failed: ${resp.status}`);
}
return (await resp.json()) as DispatchResponse;
},
async getMessage(reference) {
const resp = await fetch(`${base}/messages/${encodeURIComponent(reference)}`);
if (resp.status === 404) return null;
if (!resp.ok) throw new Error(`fin getMessage failed: ${resp.status}`);
return (await resp.json()) as FinMessage;
},
};
}
/**
* In-process client that talks to the sandbox module directly —
* avoids a round-trip through HTTP for unit tests.
*/
export async function createInProcessFinLinkClient(): Promise<FinLinkClient> {
const sandbox = await import("./sandbox");
return {
async dispatch(req) {
const msg = sandbox.recordDispatch(req);
return {
reference: msg.reference,
state: msg.state,
ackedAt: msg.updatedAt,
};
},
async getMessage(reference) {
return sandbox.getMessage(reference) ?? null;
},
};
}
/**
* Factory: returns an HTTP client if FIN_SANDBOX_URL is set, else an
* in-process client that short-circuits to the sandbox module.
*
* When falling back to the in-process sandbox we emit blocker
* EXT-FIN-GATEWAY (per proxmox/docs/03-deployment/
* EXTERNAL_DEPENDENCY_BLOCKERS.md) — that id maps 1:1 with the
* deployment checker and signals "no real FIN / Alliance Access
* transport configured yet".
*/
export async function getFinLinkClient(): Promise<FinLinkClient> {
const url = process.env.FIN_SANDBOX_URL;
if (url) {
const { logger } = await import("../../logging/logger");
logger.info(
{ baseUrl: url, mode: "live" },
"[FinLink] HTTP client (FIN_SANDBOX_URL)",
);
return createHttpFinLinkClient(url);
}
const { logger } = await import("../../logging/logger");
logger.info(
{ mode: "sandbox", blockerId: "EXT-FIN-GATEWAY" },
"[FinLink] in-process sandbox (no FIN_SANDBOX_URL — blocker EXT-FIN-GATEWAY active)",
);
return createInProcessFinLinkClient();
}

View File

@@ -1,28 +0,0 @@
/**
* FIN-link public surface.
*/
export {
buildSandboxRouter,
recordDispatch,
advance,
rejectMessage,
getMessage,
listMessages,
resetSandboxForTests,
startAutoProgress,
stopAutoProgress,
finSignature,
type FinMessage,
type FinMessageState,
type FinMessageType,
type DispatchRequest,
type DispatchResponse,
} from "./sandbox";
export {
createHttpFinLinkClient,
createInProcessFinLinkClient,
getFinLinkClient,
type FinLinkClient,
} from "./client";

View File

@@ -1,274 +0,0 @@
/**
* FIN-link sandbox (gap-analysis v2 §7.1 / §10.6).
*
* The SWIFT generators under `services/swift/` produce strings — but
* the architecture note §4.3 requires an actual transport. Until a
* production FIN-link / Alliance Access integration ships, this
* sandbox service stands in as the outbound transport so the full
* lifecycle (dispatch → ack → accept → settle) can be exercised end
* to end in dev + E2E.
*
* The sandbox:
*
* 1. Accepts an outbound SWIFT/ISO payload via POST /dispatch.
* 2. Assigns a FIN reference, stores the message in memory, and
* returns a synchronous ack (200).
* 3. Advances the message through a deterministic lifecycle:
* received -> acknowledged -> accepted -> settled
* on each tick of an internal clock (configurable via
* setTickIntervalMs for tests).
* 4. Exposes GET /messages/:reference + GET /messages for polling.
* 5. Optionally POSTs a webhook on each state change when a caller
* supplies `webhookUrl` in the dispatch request.
*
* The sandbox is intentionally process-local. Production transports
* should back this interface with a real FIN queue / Alliance Web
* Platform gateway.
*/
import { createHmac, randomBytes } from "crypto";
import express, { Router, type Request, type Response } from "express";
export type FinMessageState =
| "received"
| "acknowledged"
| "accepted"
| "settled"
| "rejected";
export type FinMessageType =
| "MT760"
| "MT202"
| "pacs.009"
| "pacs.008"
| "camt.025"
| "camt.054"
| "unknown";
export interface FinMessage {
reference: string;
messageType: FinMessageType;
payload: string;
state: FinMessageState;
receivedAt: string;
updatedAt: string;
stateHistory: Array<{ state: FinMessageState; at: string }>;
webhookUrl?: string;
planId?: string;
endToEndId?: string;
}
export interface DispatchRequest {
messageType: FinMessageType;
payload: string;
planId?: string;
endToEndId?: string;
webhookUrl?: string;
}
export interface DispatchResponse {
reference: string;
state: FinMessageState;
ackedAt: string;
}
const store = new Map<string, FinMessage>();
// Deterministic lifecycle progression.
const ORDER: FinMessageState[] = [
"received",
"acknowledged",
"accepted",
"settled",
];
function nextState(current: FinMessageState): FinMessageState | null {
const idx = ORDER.indexOf(current);
if (idx < 0 || idx === ORDER.length - 1) return null;
return ORDER[idx + 1];
}
function genReference(): string {
return `FIN-${randomBytes(6).toString("hex").toUpperCase()}`;
}
export function finSignature(payload: string): string {
const secret = process.env.FIN_SANDBOX_SECRET || "fin-sandbox-dev-secret";
return createHmac("sha256", secret).update(payload).digest("hex");
}
export function recordDispatch(req: DispatchRequest): FinMessage {
const reference = genReference();
const now = new Date().toISOString();
const msg: FinMessage = {
reference,
messageType: req.messageType,
payload: req.payload,
state: "received",
receivedAt: now,
updatedAt: now,
stateHistory: [{ state: "received", at: now }],
webhookUrl: req.webhookUrl,
planId: req.planId,
endToEndId: req.endToEndId,
};
store.set(reference, msg);
return msg;
}
export async function advance(reference: string): Promise<FinMessage | null> {
const msg = store.get(reference);
if (!msg) return null;
const next = nextState(msg.state);
if (!next) return msg;
const at = new Date().toISOString();
msg.state = next;
msg.updatedAt = at;
msg.stateHistory.push({ state: next, at });
if (msg.webhookUrl) {
await emitWebhook(msg).catch(() => undefined);
}
return msg;
}
export function rejectMessage(
reference: string,
reason: string,
): FinMessage | null {
const msg = store.get(reference);
if (!msg) return null;
const at = new Date().toISOString();
msg.state = "rejected";
msg.updatedAt = at;
msg.stateHistory.push({ state: "rejected", at });
(msg as FinMessage & { rejectionReason?: string }).rejectionReason = reason;
return msg;
}
export function getMessage(reference: string): FinMessage | undefined {
return store.get(reference);
}
export function listMessages(filter?: { planId?: string }): FinMessage[] {
const all = Array.from(store.values());
if (!filter?.planId) return all;
return all.filter((m) => m.planId === filter.planId);
}
export function resetSandboxForTests(): void {
store.clear();
}
async function emitWebhook(msg: FinMessage): Promise<void> {
if (!msg.webhookUrl) return;
const body = JSON.stringify({
reference: msg.reference,
messageType: msg.messageType,
state: msg.state,
updatedAt: msg.updatedAt,
planId: msg.planId,
endToEndId: msg.endToEndId,
});
const signature = finSignature(body);
try {
await fetch(msg.webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-fin-sandbox-signature": signature,
},
body,
});
} catch {
// swallow — the sandbox is best-effort in dev
}
}
// ---------------------------------------------------------------------------
// HTTP router
// ---------------------------------------------------------------------------
export function buildSandboxRouter(): Router {
const r = Router();
r.use(express.json({ limit: "5mb" }));
r.post("/dispatch", (req: Request, res: Response) => {
const body = req.body as Partial<DispatchRequest>;
if (
!body ||
typeof body.payload !== "string" ||
typeof body.messageType !== "string"
) {
return res.status(400).json({
error: "messageType and payload are required",
});
}
const msg = recordDispatch({
messageType: body.messageType as FinMessageType,
payload: body.payload,
planId: body.planId,
endToEndId: body.endToEndId,
webhookUrl: body.webhookUrl,
});
const response: DispatchResponse = {
reference: msg.reference,
state: msg.state,
ackedAt: msg.updatedAt,
};
return res.status(202).json(response);
});
r.post("/advance/:reference", async (req: Request, res: Response) => {
const msg = await advance(req.params.reference);
if (!msg) return res.status(404).json({ error: "not found" });
return res.json(msg);
});
r.post("/reject/:reference", (req: Request, res: Response) => {
const reason =
typeof req.body?.reason === "string" ? req.body.reason : "rejected";
const msg = rejectMessage(req.params.reference, reason);
if (!msg) return res.status(404).json({ error: "not found" });
return res.json(msg);
});
r.get("/messages/:reference", (req: Request, res: Response) => {
const msg = getMessage(req.params.reference);
if (!msg) return res.status(404).json({ error: "not found" });
return res.json(msg);
});
r.get("/messages", (req: Request, res: Response) => {
const planId =
typeof req.query.planId === "string" ? req.query.planId : undefined;
return res.json({ messages: listMessages({ planId }) });
});
return r;
}
// ---------------------------------------------------------------------------
// Timer-driven auto-progress (optional; off by default in tests)
// ---------------------------------------------------------------------------
let tickTimer: NodeJS.Timeout | null = null;
export function startAutoProgress(intervalMs = 2_000): void {
stopAutoProgress();
tickTimer = setInterval(() => {
for (const msg of store.values()) {
if (msg.state !== "settled" && msg.state !== "rejected") {
void advance(msg.reference);
}
}
}, intervalMs);
// Allow the Node process to exit while this timer is pending.
if (typeof tickTimer.unref === "function") tickTimer.unref();
}
export function stopAutoProgress(): void {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = null;
}
}

View File

@@ -25,12 +25,7 @@ import { logger } from "../logging/logger";
import type { Plan } from "../types/plan";
const NOTARY_REGISTRY_ABI = [
// Step tuple order must match IComboHandler.Step exactly:
// (StepType stepType, bytes data, address target, uint256 value)
// Any divergence changes the canonical signature and therefore the
// function selector — the call would silently miss and the contract
// would revert with no revert data.
"function registerPlan(bytes32 planId, tuple(uint8 stepType, bytes data, address target, uint256 value)[] steps, address creator) external",
"function registerPlan(bytes32 planId, tuple(uint8 stepType, address target, uint256 amount, bytes data)[] steps, address creator) external",
"function finalizePlan(bytes32 planId, bool success) external",
"function getPlan(bytes32 planId) view returns (tuple(bytes32 planHash, address creator, uint256 registeredAt, uint256 finalizedAt, bool success, bytes32 receiptHash))",
"event PlanRegistered(bytes32 indexed planId, address indexed creator, bytes32 planHash)",
@@ -113,13 +108,7 @@ function getContract(cfg: NotaryConfig): {
if (cached && cached.cfg.contractAddress === cfg.contractAddress) {
return { contract: cached.contract, wallet: cached.wallet };
}
// cacheTimeout=-1 disables the 250ms response cache — otherwise
// back-to-back anchor+finalize calls read a stale getTransactionCount
// and collide on nonce, particularly on fast (ganache/hardhat) chains.
const provider = new ethers.JsonRpcProvider(cfg.rpcUrl, cfg.chainId, {
staticNetwork: true,
cacheTimeout: -1,
});
const provider = new ethers.JsonRpcProvider(cfg.rpcUrl);
const wallet = new ethers.Wallet(cfg.privateKey!, provider);
const contract = new ethers.Contract(
cfg.contractAddress!,

View File

@@ -1,45 +0,0 @@
/**
* Obligation-layer condition evaluator.
*
* Originally shipped as a self-contained subset of the PR P Rules
* Engine so the obligation layer could be merged independently. Now
* consolidated: this file re-exports the shared types and
* `evaluateCondition` from `services/rulesEngine.ts` and provides a
* thin compatibility wrapper for `resolvePath(path, context)` which
* historically took its arguments in the opposite order.
*
* Keeping this module as a named surface preserves existing imports
* under `services/obligations/evaluator` throughout the codebase and
* the test suite.
*/
export type {
Operator,
LeafCondition,
AndCondition,
OrCondition,
NotCondition,
Condition,
} from "../rulesEngine";
import { evaluateCondition as ruleEngineEvaluate, resolvePath as ruleEnginePath } from "../rulesEngine";
import type { Condition } from "../rulesEngine";
export function evaluateCondition(
condition: Condition,
context: Record<string, unknown>,
): boolean {
return ruleEngineEvaluate(condition, context);
}
/**
* Historical (path, context) signature retained for backward
* compatibility with call sites written before the evaluator was
* consolidated into the Rules Engine.
*/
export function resolvePath(
path: string,
context: Record<string, unknown>,
): unknown {
return ruleEnginePath(context, path);
}

View File

@@ -1,320 +0,0 @@
/**
* Machine-form obligation layer — entry point.
*
* See ./types.ts for the architectural shape; this module exposes:
* - canonicalize / hashObligationTerms (deterministic identity)
* - validateObligationTerms (shape check)
* - evaluateObligationTerms (run commit/abort/unwind
* clauses against a context
* via the PR P rules engine)
* - buildIssueInstrumentObligation (helper that derives a
* sensible default obligation
* shape from a plan's
* instrument terms)
*/
import { createHash } from "crypto";
import { evaluateCondition } from "./evaluator";
import type { InstrumentTerms } from "../../types/plan";
import type {
AuthorizedParticipant,
Consideration,
EvaluationResult,
GoverningDocument,
ObligationClause,
ObligationEvaluation,
ObligationTerms,
} from "./types";
export * from "./types";
/**
* Deterministic canonical JSON encoding: object keys sorted
* lexicographically at every depth, arrays preserved, no whitespace.
*
* This is what `hashObligationTerms()` hashes, so two obligations
* with identical semantic content always hash to the same value
* regardless of key insertion order.
*/
export function canonicalize(value: unknown): string {
return JSON.stringify(sortValue(value));
}
function sortValue(v: unknown): unknown {
if (v === null || typeof v !== "object") return v;
if (Array.isArray(v)) return v.map((x) => sortValue(x));
const out: Record<string, unknown> = {};
for (const k of Object.keys(v as Record<string, unknown>).sort()) {
out[k] = sortValue((v as Record<string, unknown>)[k]);
}
return out;
}
/**
* SHA-256 of the canonical obligation terms, hex-encoded without
* 0x prefix. Matches the formatting convention used by
* `InstrumentTerms.templateHash`.
*/
export function hashObligationTerms(terms: ObligationTerms): string {
return createHash("sha256").update(canonicalize(terms)).digest("hex");
}
/**
* Shape validation. Returns a list of human-readable problems; empty
* list means the object conforms to `ObligationTerms`.
*
* Intentionally cheap (no JSON-Schema runtime) — the TypeScript type
* plus these assertions catch the bulk of real-world mistakes.
*/
export function validateObligationTerms(
input: unknown,
): { ok: boolean; errors: string[] } {
const errors: string[] = [];
if (!input || typeof input !== "object") {
return { ok: false, errors: ["obligation terms must be an object"] };
}
const t = input as Partial<ObligationTerms>;
if (t.version !== "1.0") errors.push("version must be \"1.0\"");
if (!t.consideration || typeof t.consideration !== "object") {
errors.push("consideration missing");
} else {
const c = t.consideration as Partial<Consideration>;
if (!c.payor) errors.push("consideration.payor required");
if (!c.payee) errors.push("consideration.payee required");
if (!c.currency || !/^[A-Z]{3}$/.test(c.currency))
errors.push("consideration.currency must be ISO-4217 (3 uppercase letters)");
if (typeof c.amount !== "number" || !(c.amount > 0))
errors.push("consideration.amount must be a positive number");
}
for (const arrKey of [
"validIssuance",
"validPayment",
"commit",
"abort",
"unwind",
] as const) {
const arr = t[arrKey];
if (!Array.isArray(arr)) {
errors.push(`${arrKey} must be an array`);
continue;
}
arr.forEach((clause, i) => {
if (!clause || typeof clause !== "object") {
errors.push(`${arrKey}[${i}] must be an object`);
return;
}
const c = clause as Partial<ObligationClause>;
if (!c.id) errors.push(`${arrKey}[${i}].id required`);
if (!c.description) errors.push(`${arrKey}[${i}].description required`);
if (!c.assert) errors.push(`${arrKey}[${i}].assert required`);
if (c.binds && !["instrument", "payment", "both"].includes(c.binds))
errors.push(`${arrKey}[${i}].binds must be instrument|payment|both`);
});
}
if (!Array.isArray(t.authorizedParticipants)) {
errors.push("authorizedParticipants must be an array");
} else {
t.authorizedParticipants.forEach((p, i) => {
const pp = p as Partial<AuthorizedParticipant>;
if (!pp.role) errors.push(`authorizedParticipants[${i}].role required`);
if (!pp.actorId)
errors.push(`authorizedParticipants[${i}].actorId required`);
});
}
if (!Array.isArray(t.governingDocuments) || t.governingDocuments.length === 0) {
errors.push("governingDocuments must be a non-empty array");
} else {
t.governingDocuments.forEach((d, i) => {
const dd = d as Partial<GoverningDocument>;
if (!dd.templateRef)
errors.push(`governingDocuments[${i}].templateRef required`);
if (!dd.templateHash || !/^[0-9a-fA-F]{64}$/.test(dd.templateHash))
errors.push(`governingDocuments[${i}].templateHash must be hex SHA-256`);
});
}
return { ok: errors.length === 0, errors };
}
/**
* Evaluate a set of obligation clauses against a live context.
*
* `context` typically contains the plan, execution state, event chain,
* and bank/DLT dispatch evidence — whatever the clauses assert against.
*
* A failure short-circuits nothing; all clauses are evaluated so the
* caller can surface the full list of unmet conditions (arch §12.2).
*/
export function evaluateClauses(
clauses: ObligationClause[],
context: Record<string, unknown>,
): ObligationEvaluation {
const results: EvaluationResult[] = clauses.map((clause) => {
let ok = false;
let failureReason: string | undefined;
try {
ok = evaluateCondition(clause.assert, context);
if (!ok) failureReason = "assert condition returned false";
} catch (err) {
ok = false;
failureReason =
err instanceof Error ? err.message : "unknown evaluator error";
}
return {
clauseId: clause.id,
description: clause.description,
ok,
...(failureReason ? { failureReason } : {}),
};
});
return { ok: results.every((r) => r.ok), results };
}
/**
* Evaluate specifically the commit clauses. Convenience for the
* transition coordinator (arch §9.2).
*/
export function evaluateCommit(
terms: ObligationTerms,
context: Record<string, unknown>,
): ObligationEvaluation {
return evaluateClauses(terms.commit, context);
}
/**
* Evaluate specifically the abort clauses (arch §9.3). A true result
* here means the transaction MUST abort.
*/
export function evaluateAbort(
terms: ObligationTerms,
context: Record<string, unknown>,
): ObligationEvaluation {
const ev = evaluateClauses(terms.abort, context);
// Semantically an abort clause that *asserts true* means the abort
// condition has been hit, so `ok=true` in the evaluation result ==
// "abort required". Callers consume this as a boolean trigger.
return ev;
}
/**
* Derive a default obligation-terms object from an issueInstrument
* step's instrument terms. Useful for plans that haven't supplied an
* explicit obligation block — gives them a reasonable starting point
* that matches the template's commit/abort semantics.
*/
export function buildIssueInstrumentObligation(input: {
instrument: InstrumentTerms;
payor: string;
payee: string;
authorizedParticipants: AuthorizedParticipant[];
governingDocumentTitle?: string;
}): ObligationTerms {
const { instrument, payor, payee, authorizedParticipants } = input;
const commit: ObligationClause[] = [
{
id: "commit.dlt_tx_hash",
description: "DLT anchor transaction hash is present and valid",
binds: "both",
assert: {
path: "dlt.tx_hash",
op: "matches",
value: "^0x[0-9a-fA-F]{64}$",
},
},
{
id: "commit.bank_iso_message_id",
description: "Bank leg has produced an ISO-20022 message id",
binds: "instrument",
assert: { path: "bank.iso_message_id", op: "exists" },
},
{
id: "commit.state_is_validating",
description: "Transaction must be in VALIDATING when commit fires",
binds: "both",
assert: { path: "state", op: "eq", value: "VALIDATING" },
},
];
const abort: ObligationClause[] = [
{
id: "abort.exception_raised",
description: "At least one active exception blocks commit",
binds: "both",
assert: { path: "exceptions.active", op: "length_gte", value: 1 },
},
];
const unwind: ObligationClause[] = [
{
id: "unwind.payment_failed_only",
description:
"Unwind applies only when the payment leg failed AFTER the "
+ "instrument was dispatched (MT760 is irrevocable under UCP 600).",
binds: "payment",
assert: {
all: [
{ path: "instrument.dispatched", op: "eq", value: true },
{ path: "payment.failed", op: "eq", value: true },
],
},
},
];
const validIssuance: ObligationClause[] = [
{
id: "issuance.template_hash_matches",
description: "Dispatched instrument text hashes to the agreed template",
binds: "instrument",
assert: {
path: "instrument.template_hash",
op: "eq",
value: instrument.templateHash,
},
},
];
const validPayment: ObligationClause[] = [
{
id: "payment.amount_matches",
description: "Payment amount equals the instrument face value",
binds: "payment",
assert: { path: "payment.amount", op: "eq", value: instrument.amount },
},
{
id: "payment.currency_matches",
description: "Payment currency equals the instrument currency",
binds: "payment",
assert: { path: "payment.currency", op: "eq", value: instrument.currency },
},
];
return {
version: "1.0",
consideration: {
payor,
payee,
currency: instrument.currency,
amount: instrument.amount,
},
validIssuance,
validPayment,
commit,
abort,
unwind,
authorizedParticipants,
governingDocuments: [
{
templateRef: instrument.templateRef,
templateHash: instrument.templateHash,
title: input.governingDocumentTitle,
governingLaw: instrument.governingLaw,
},
],
};
}

View File

@@ -1,135 +0,0 @@
/**
* Machine-form obligation layer (gap-analysis v2 §4.1 partial).
*
* Architecture §4.1 "Legal / Obligation Layer" describes what the
* transaction's terms must express: consideration, commit conditions,
* abort conditions, unwind conditions, authorized-participant matrix,
* and a reference to governing documents.
*
* Until now a Plan only stored a `templateHash` — a hash reference
* to an off-chain text. That satisfies tamper-evidence but is not
* machine-enforceable: the orchestrator can't tell whether a given
* execution context *satisfies* the terms without a human reading
* the underlying PDF.
*
* This module makes the obligation layer first-class data:
*
* - Strongly typed shape for the six architectural sub-objects
* (consideration, validIssuance, validPayment, commit, abort,
* unwind, authorizedParticipants, governingDocuments).
* - Canonicalisation + SHA-256 hash (deterministic, replayable).
* - Executable assertions built on the PR P Rules Engine DSL so
* commit/abort/unwind conditions can be checked automatically
* against a live context.
*
* Binds to the existing `InstrumentTerms.templateHash` field: an
* ObligationTerms instance records the governing-document hash as
* one of its `governingDocuments[]` entries, closing the loop from
* "which document governs this plan" to "what does that document
* require, expressed as machine-checkable predicates".
*/
import type { Condition } from "./evaluator";
/**
* Commercial and legal meaning of the transaction (arch §4.1).
*/
export interface Consideration {
/** Who pays and what. */
payor: string;
payee: string;
/** ISO-4217 currency code. */
currency: string;
/** Positive amount in major units (e.g. 100.00 USD = 100). */
amount: number;
/** Optional free-form description of the consideration. */
description?: string;
}
/**
* Role entry on the authorized-participant matrix. Roles match the
* SoD set used by middleware/apiKeyAuth (PR M): coordinator, approver,
* releaser, validator, exception_manager, operator.
*/
export interface AuthorizedParticipant {
role:
| "coordinator"
| "approver"
| "releaser"
| "validator"
| "exception_manager"
| "operator";
/** Free-form identifier — an actor id, API-key id, or wallet address. */
actorId: string;
/** Optional display name. */
displayName?: string;
}
/**
* Governing-document reference: template id + integrity hash of the
* agreed text (see InstrumentTerms.templateHash).
*/
export interface GoverningDocument {
/** Stable template identifier (e.g. "emirates-islamic-sblc-v3"). */
templateRef: string;
/** Hex SHA-256 of the canonical agreed text, without 0x prefix. */
templateHash: string;
/** Optional human-readable title. */
title?: string;
/** Optional ruleset the template is governed under. */
governingLaw?: string;
}
/**
* A single machine-enforceable clause. The `assert` field is a
* rulesEngine Condition so the obligation layer can reuse the
* evaluator from PR P.
*/
export interface ObligationClause {
id: string;
description: string;
/** Rules-engine condition that must hold for the clause to be satisfied. */
assert: Condition;
/** Explicitly surface which side of the transaction the clause binds. */
binds: "instrument" | "payment" | "both";
}
/**
* Top-level obligation-terms object.
*
* Canonicalisation:
* - Keys are sorted lexicographically via `canonicalize()`.
* - `terms_hash` = SHA-256 of the canonical JSON string.
*
* The hash is the identity of the obligation: two plans with the
* same hash have identical machine-enforceable terms.
*/
export interface ObligationTerms {
/** Schema version — bump on any breaking shape change. */
version: "1.0";
consideration: Consideration;
/** Clauses that define what "valid issuance" means (arch §4.1). */
validIssuance: ObligationClause[];
/** Clauses that define what "valid payment" means (arch §4.1). */
validPayment: ObligationClause[];
/** Commit criteria (arch §9.2). */
commit: ObligationClause[];
/** Abort criteria (arch §9.3). */
abort: ObligationClause[];
/** Unwind procedures (arch §8 UNWIND_PENDING). */
unwind: ObligationClause[];
authorizedParticipants: AuthorizedParticipant[];
governingDocuments: GoverningDocument[];
}
export interface EvaluationResult {
clauseId: string;
description: string;
ok: boolean;
failureReason?: string;
}
export interface ObligationEvaluation {
ok: boolean;
results: EvaluationResult[];
}

View File

@@ -1,308 +0,0 @@
/**
* Pluggable Rules Engine (arch §5.2 Rules Engine; gap v2 §5.2 partial).
*
* Before this PR, business rules were hardcoded at the call sites
* (e.g. "plan must have a pay step" baked into iso20022.ts, SoD
* matrix hard-coded in transactionState.ts). This module introduces
* a minimal, declarative JSON DSL so that ruleSets can be loaded
* from env (RULES_FILE) or swapped per-environment.
*
* Design principles
* -----------------
* - No eval. The evaluator is a small recursive switch over a
* closed operator set — no runtime code injection.
* - Pure, deterministic, side-effect free. Evaluation order is
* explicit so the engine can be reasoned about and replayed.
* - Context is a flat name → value map. Callers project whatever
* shape they need ({plan, state, compliance, participants}).
* - Failures are collected, not thrown. The caller decides whether
* a single failure aborts, or whether to accumulate and report.
*/
import { readFileSync } from "fs";
/** Supported primitive operators. */
export type Operator =
| "eq"
| "neq"
| "gt"
| "gte"
| "lt"
| "lte"
| "in"
| "not_in"
| "exists"
| "matches" // regex
| "length_gte"
| "length_lte";
/** Leaf condition — references a context path against a literal. */
export interface LeafCondition {
path: string; // dotted path into the context object
op: Operator;
value?: unknown; // not required for `exists`
/** Optional human label for failure messages. */
message?: string;
}
/** Combinator — AND / OR / NOT over child conditions. */
export interface AndCondition {
all: Condition[];
message?: string;
}
export interface OrCondition {
any: Condition[];
message?: string;
}
export interface NotCondition {
not: Condition;
message?: string;
}
export type Condition = LeafCondition | AndCondition | OrCondition | NotCondition;
export interface Rule {
id: string;
description?: string;
when?: Condition; // precondition — rule only fires when `when` is true
assert: Condition; // the rule passes when `assert` evaluates true
/** Optional severity for reporting: "error" (default) blocks, "warn" does not. */
severity?: "error" | "warn";
}
export interface RuleSet {
id: string;
version?: string;
rules: Rule[];
}
export interface RuleFailure {
ruleId: string;
severity: "error" | "warn";
message: string;
path?: string;
}
export interface EvaluationResult {
ok: boolean;
failures: RuleFailure[];
}
/* -----------------------------------------------------------------
* Dotted-path resolver. Supports a.b.c and a.b[0].c.
* --------------------------------------------------------------- */
export function resolvePath(ctx: unknown, path: string): unknown {
return getPath(ctx, path);
}
function getPath(ctx: unknown, path: string): unknown {
if (!path) return ctx;
const parts = path
.replace(/\[(\d+)\]/g, ".$1")
.split(".")
.filter(Boolean);
let cur: unknown = ctx;
for (const p of parts) {
if (cur === null || cur === undefined) return undefined;
if (typeof cur === "object") {
cur = (cur as Record<string, unknown>)[p];
} else {
return undefined;
}
}
return cur;
}
/* -----------------------------------------------------------------
* Operator evaluation. Pure — no throws.
* --------------------------------------------------------------- */
function evalOp(op: Operator, actual: unknown, expected: unknown): boolean {
switch (op) {
case "eq":
return actual === expected;
case "neq":
return actual !== expected;
case "gt":
return typeof actual === "number" && typeof expected === "number" && actual > expected;
case "gte":
return typeof actual === "number" && typeof expected === "number" && actual >= expected;
case "lt":
return typeof actual === "number" && typeof expected === "number" && actual < expected;
case "lte":
return typeof actual === "number" && typeof expected === "number" && actual <= expected;
case "in":
return Array.isArray(expected) && expected.includes(actual as never);
case "not_in":
return Array.isArray(expected) && !expected.includes(actual as never);
case "exists":
return actual !== undefined && actual !== null;
case "matches":
if (typeof actual !== "string" || typeof expected !== "string") return false;
try {
return new RegExp(expected).test(actual);
} catch {
return false;
}
case "length_gte":
if (!Array.isArray(actual) && typeof actual !== "string") return false;
return (actual as { length: number }).length >= (expected as number);
case "length_lte":
if (!Array.isArray(actual) && typeof actual !== "string") return false;
return (actual as { length: number }).length <= (expected as number);
default:
return false;
}
}
function isLeaf(c: Condition): c is LeafCondition {
return (c as LeafCondition).op !== undefined && (c as LeafCondition).path !== undefined;
}
export function evaluateCondition(
condition: Condition,
context: Record<string, unknown>,
): boolean {
if (isLeaf(condition)) {
const actual = getPath(context, condition.path);
return evalOp(condition.op, actual, condition.value);
}
if ("all" in condition) {
return condition.all.every((c) => evaluateCondition(c, context));
}
if ("any" in condition) {
return condition.any.some((c) => evaluateCondition(c, context));
}
if ("not" in condition) {
return !evaluateCondition(condition.not, context);
}
return false;
}
/* -----------------------------------------------------------------
* Public evaluate(): runs the full rule set and collects failures.
* --------------------------------------------------------------- */
export function evaluate(
ruleSet: RuleSet,
context: Record<string, unknown>,
): EvaluationResult {
const failures: RuleFailure[] = [];
for (const rule of ruleSet.rules) {
if (rule.when && !evaluateCondition(rule.when, context)) continue;
const passed = evaluateCondition(rule.assert, context);
if (!passed) {
failures.push({
ruleId: rule.id,
severity: rule.severity ?? "error",
message: rule.description ?? `rule ${rule.id} failed`,
path: isLeaf(rule.assert) ? rule.assert.path : undefined,
});
}
}
const blocking = failures.filter((f) => f.severity === "error");
return { ok: blocking.length === 0, failures };
}
/* -----------------------------------------------------------------
* Built-in rule sets. These mirror the pre-DSL hardcoded checks so
* callers can migrate incrementally.
* --------------------------------------------------------------- */
/** Preconditions check — arch §8 PRECONDITIONS_PENDING -> READY_FOR_PREPARE. */
export const BUILTIN_PRECONDITIONS: RuleSet = {
id: "preconditions.builtin",
version: "1",
rules: [
{
id: "plan.exists",
description: "plan must be present on the context",
assert: { path: "plan", op: "exists" },
},
{
id: "plan.steps.non_empty",
description: "plan must contain at least one step",
assert: { path: "plan.steps", op: "length_gte", value: 1 },
},
{
id: "plan.pay_step_present",
description: "plan must contain at least one pay step (ISO-20022 envelope)",
assert: {
any: [
{ path: "plan.steps[0].type", op: "eq", value: "pay" },
{ path: "plan.steps[1].type", op: "eq", value: "pay" },
{ path: "plan.steps[2].type", op: "eq", value: "pay" },
{ path: "plan.steps[3].type", op: "eq", value: "pay" },
],
},
},
{
id: "participants.at_least_one",
description: "participant registry must not be empty",
assert: { path: "participants", op: "length_gte", value: 1 },
},
{
id: "compliance.kyc_ok",
description: "compliance KYC status must be ok",
when: { path: "compliance", op: "exists" },
assert: { path: "compliance.kyc", op: "eq", value: "ok" },
},
],
};
/** Commit rule — arch §9.2. */
export const BUILTIN_COMMIT: RuleSet = {
id: "commit.builtin",
version: "1",
rules: [
{
id: "dlt.tx_hash",
description: "DLT leg must produce a 0x + 64-hex tx hash",
assert: { path: "dlt.txHash", op: "matches", value: "^0x[0-9a-fA-F]{64}$" },
},
{
id: "bank.iso_message_id",
description: "bank leg must produce a non-empty ISO message id",
assert: { path: "bank.isoMessageId", op: "exists" },
},
{
id: "state.is_validating",
description: "commit is only valid from VALIDATING",
assert: { path: "state", op: "eq", value: "VALIDATING" },
},
{
id: "no_exception_holds",
description: "no exception may be outstanding",
assert: { path: "exceptions.active", op: "length_lte", value: 0 },
},
],
};
/* -----------------------------------------------------------------
* Loader: RULES_FILE env points at a JSON file containing a map
* {ruleSetId: RuleSet}. Falls back to built-ins on any error.
* --------------------------------------------------------------- */
let cachedOverrides: Record<string, RuleSet> | undefined;
export function getRuleSet(id: string): RuleSet {
if (cachedOverrides === undefined) {
cachedOverrides = {};
const path = process.env.RULES_FILE;
if (path) {
try {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as Record<string, RuleSet>;
if (parsed && typeof parsed === "object") cachedOverrides = parsed;
} catch {
// leave empty — silent fall-through to built-ins
}
}
}
if (cachedOverrides[id]) return cachedOverrides[id];
if (id === BUILTIN_PRECONDITIONS.id) return BUILTIN_PRECONDITIONS;
if (id === BUILTIN_COMMIT.id) return BUILTIN_COMMIT;
return { id, rules: [] };
}
export function __resetRulesCacheForTests(): void {
cachedOverrides = undefined;
}

View File

@@ -1,119 +0,0 @@
/**
* Unit tests for the EXT-* external-dependency blocker registry.
* Headless — no network, no UI.
*/
import {
EXT_BLOCKER_IDS,
BLOCKER_DETAILS,
evaluateBlockers,
activeBlockers,
logBlockerStatusAtBoot,
} from "../../src/config/externalBlockers";
describe("externalBlockers registry", () => {
it("exposes exactly the 7 blocker IDs the proxmox checker tracks", () => {
expect(EXT_BLOCKER_IDS).toEqual([
"EXT-DBIS-CORE",
"EXT-CC-PAYMENT-ADAPTERS",
"EXT-CC-AUDIT-LEDGER",
"EXT-CC-SHARED-EVENTS",
"EXT-CC-SHARED-SCHEMAS",
"EXT-FIN-GATEWAY",
"EXT-CHAIN138-CI-RPC",
]);
});
it("has a detail record for every id", () => {
for (const id of EXT_BLOCKER_IDS) {
expect(BLOCKER_DETAILS[id]).toBeDefined();
expect(BLOCKER_DETAILS[id].id).toBe(id);
expect(BLOCKER_DETAILS[id].title.length).toBeGreaterThan(0);
expect(BLOCKER_DETAILS[id].description.length).toBeGreaterThan(0);
}
});
});
describe("evaluateBlockers()", () => {
it("marks everything active on an empty env", () => {
const records = evaluateBlockers({});
expect(records).toHaveLength(EXT_BLOCKER_IDS.length);
expect(records.every((r) => r.status === "active")).toBe(true);
});
it("resolves EXT-DBIS-CORE when DBIS_CORE_URL is set", () => {
const records = evaluateBlockers({ DBIS_CORE_URL: "http://x.test" });
const rec = records.find((r) => r.id === "EXT-DBIS-CORE");
expect(rec?.status).toBe("resolved");
expect(rec?.resolvedVia).toBe("DBIS_CORE_URL");
});
it("resolves EXT-FIN-GATEWAY when FIN_SANDBOX_URL is set", () => {
const records = evaluateBlockers({ FIN_SANDBOX_URL: "http://fin.test" });
expect(records.find((r) => r.id === "EXT-FIN-GATEWAY")?.status).toBe("resolved");
});
it("resolves EXT-CHAIN138-CI-RPC when CHAIN_138_RPC_URL is set", () => {
const records = evaluateBlockers({
CHAIN_138_RPC_URL: "https://rpc.public-0138.defi-oracle.io",
});
expect(records.find((r) => r.id === "EXT-CHAIN138-CI-RPC")?.status).toBe("resolved");
});
it("leaves cc-* scaffold blockers active regardless of env", () => {
const records = evaluateBlockers({
DBIS_CORE_URL: "http://x",
FIN_SANDBOX_URL: "http://y",
CHAIN_138_RPC_URL: "http://z",
});
const scaffoldIds = [
"EXT-CC-PAYMENT-ADAPTERS",
"EXT-CC-AUDIT-LEDGER",
"EXT-CC-SHARED-EVENTS",
"EXT-CC-SHARED-SCHEMAS",
];
for (const id of scaffoldIds) {
expect(records.find((r) => r.id === id)?.status).toBe("active");
}
});
it("treats empty-string env var as unset (not resolved)", () => {
const records = evaluateBlockers({ DBIS_CORE_URL: "" });
expect(records.find((r) => r.id === "EXT-DBIS-CORE")?.status).toBe("active");
});
});
describe("activeBlockers()", () => {
it("returns 7 when env is empty", () => {
expect(activeBlockers({})).toHaveLength(7);
});
it("returns 6 when Chain-138 RPC is resolved", () => {
const ids = activeBlockers({
CHAIN_138_RPC_URL: "https://rpc.public-0138.defi-oracle.io",
});
expect(ids).not.toContain("EXT-CHAIN138-CI-RPC");
expect(ids).toHaveLength(6);
});
});
describe("logBlockerStatusAtBoot()", () => {
it("emits a single summary with active + resolved counts", () => {
const calls: Array<{ obj: Record<string, unknown>; msg: string }> = [];
const fakeLogger = {
info: (obj: Record<string, unknown>, msg: string) => calls.push({ obj, msg }),
};
const prev = process.env.CHAIN_138_RPC_URL;
process.env.CHAIN_138_RPC_URL = "https://rpc.public-0138.defi-oracle.io";
try {
logBlockerStatusAtBoot(fakeLogger);
} finally {
if (prev === undefined) delete process.env.CHAIN_138_RPC_URL;
else process.env.CHAIN_138_RPC_URL = prev;
}
expect(calls).toHaveLength(1);
expect(calls[0].msg).toMatch(/active,.*resolved/);
expect((calls[0].obj.activeCount as number) + (calls[0].obj.resolvedCount as number)).toBe(7);
expect(calls[0].obj.resolvedCount).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -1,163 +0,0 @@
/**
* Helper: compile contracts/NotaryRegistry.sol + its two interfaces
* + @openzeppelin/contracts Ownable using solc-js in-process.
*
* Keeps the E2E suite self-contained — no dependence on a prior
* `hardhat compile` step, no new workspace wiring.
*/
import { readFileSync } from "fs";
import { dirname, join, resolve } from "path";
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const solc = require("solc");
const REPO_ROOT = resolve(__dirname, "..", "..", "..", "..");
const CONTRACTS_ROOT = join(REPO_ROOT, "contracts");
const OZ_ROOT = join(CONTRACTS_ROOT, "node_modules", "@openzeppelin");
// ethers v6 accepts any JsonFragment-shaped array here. Declaring the
// element type loosely keeps us decoupled from ethers' private type
// exports while still being strictly typed against `unknown`.
export type AbiFragment = Record<string, unknown>;
export interface CompiledArtifact {
abi: AbiFragment[];
bytecode: string;
}
interface SolcSource {
content: string;
}
interface SolcInput {
language: "Solidity";
sources: Record<string, SolcSource>;
settings: {
optimizer: { enabled: true; runs: number };
outputSelection: Record<string, Record<string, string[]>>;
};
}
interface SolcOutput {
errors?: Array<{ severity: "error" | "warning"; formattedMessage: string }>;
contracts: Record<
string,
Record<string, { abi: AbiFragment[]; evm: { bytecode: { object: string } } }>
>;
}
function readFromRoots(rel: string, roots: string[]): string {
for (const root of roots) {
try {
return readFileSync(join(root, rel), "utf8");
} catch {
// try next root
}
}
throw new Error(`Could not resolve import ${rel} against roots ${roots.join(",")}`);
}
function findImports(requestedPath: string): { contents: string } | { error: string } {
// @openzeppelin/... → contracts/node_modules/@openzeppelin/...
if (requestedPath.startsWith("@openzeppelin/")) {
const rel = requestedPath.replace("@openzeppelin/", "");
try {
return { contents: readFileSync(join(OZ_ROOT, rel), "utf8") };
} catch (e) {
return { error: `Could not read ${requestedPath}: ${(e as Error).message}` };
}
}
// Local ./interfaces/... paths resolve against contracts/
try {
return { contents: readFromRoots(requestedPath, [CONTRACTS_ROOT]) };
} catch (e) {
return { error: (e as Error).message };
}
}
/**
* Recursively pull in all `import "..."` references starting from
* NotaryRegistry.sol and return the full `sources` object solc needs.
*/
function collectSources(entryPath: string): Record<string, SolcSource> {
const sources: Record<string, SolcSource> = {};
const stack: string[] = [entryPath];
const seen = new Set<string>();
while (stack.length > 0) {
const cur = stack.pop()!;
if (seen.has(cur)) continue;
seen.add(cur);
let content: string;
if (cur === entryPath) {
content = readFileSync(join(CONTRACTS_ROOT, "NotaryRegistry.sol"), "utf8");
} else {
const resolved = findImports(cur);
if ("error" in resolved) {
throw new Error(`Unresolved import: ${cur} (${resolved.error})`);
}
content = resolved.contents;
}
sources[cur] = { content };
// Parse `import "..."` statements. Interfaces may use relative paths
// that we normalise back into keys solc expects.
const importRe = /^\s*import\s+(?:\{[^}]+\}\s+from\s+)?"([^"]+)";/gm;
let m: RegExpExecArray | null;
while ((m = importRe.exec(content)) !== null) {
const rawImport = m[1];
let normalised: string;
if (rawImport.startsWith("@openzeppelin/")) {
normalised = rawImport;
} else if (rawImport.startsWith("./") || rawImport.startsWith("../")) {
// Relative import — resolve against the dir of `cur`.
const curDir = cur.includes("/") ? dirname(cur) : ".";
const joined = join(curDir, rawImport);
normalised = joined.startsWith(".") ? joined.slice(2) : joined;
} else {
normalised = rawImport;
}
if (!seen.has(normalised)) stack.push(normalised);
}
}
return sources;
}
export function compileNotaryRegistry(): CompiledArtifact {
const entry = "NotaryRegistry.sol";
const sources = collectSources(entry);
const input: SolcInput = {
language: "Solidity",
sources,
settings: {
optimizer: { enabled: true, runs: 200 },
outputSelection: {
"*": { "*": ["abi", "evm.bytecode.object"] },
},
},
};
const output: SolcOutput = JSON.parse(
solc.compile(JSON.stringify(input), { import: findImports }),
);
const fatal = (output.errors ?? []).filter((e) => e.severity === "error");
if (fatal.length > 0) {
const msg = fatal.map((e) => e.formattedMessage).join("\n");
throw new Error(`solc compile failed:\n${msg}`);
}
const artifact = output.contracts[entry]?.NotaryRegistry;
if (!artifact) {
throw new Error("NotaryRegistry not found in solc output");
}
return {
abi: artifact.abi,
bytecode: "0x" + artifact.evm.bytecode.object,
};
}

View File

@@ -1,151 +0,0 @@
/**
* Read-only E2E round-trip against the **public Chain 138 RPC**.
*
* Whereas `notaryChainRoundtrip.e2e.test.ts` spins up ganache locally
* and exercises both writes and reads, this suite targets the real
* public endpoint (`https://rpc.public-0138.defi-oracle.io`) and
* closes the proxmox `EXT-CHAIN138-CI-RPC` blocker on the
* CurrenciCombo side.
*
* It does **not** perform any writes:
* - we don't own a funded key on Chain 138 in CI;
* - writes against mainnet-equivalent infra would be reckless and
* non-deterministic.
*
* What it does do:
* 1. Prove the orchestrator's ethers client can reach the public RPC.
* 2. Verify `eth_chainId` matches the expected Chain 138.
* 3. Verify `eth_blockNumber` returns a plausible current height.
* 4. If `NOTARY_REGISTRY_ADDRESS` is set, read a synthetic
* `plans(bytes32)` key and assert the contract responded (zeros
* are fine — the call succeeding is the point).
* 5. Build an orchestrator notaryChain config pointed at the real
* chain and confirm the module still gracefully mock-falls-back
* when the orchestrator's signing key isn't set.
*
* Gated on **BOTH** `RUN_E2E=1` and `E2E_USE_PUBLIC_CHAIN138=1` so the
* default E2E path stays offline.
*/
import { JsonRpcProvider, Contract, id as keccakId, ZeroHash } from "ethers";
import { compileNotaryRegistry } from "./helpers/compileNotaryRegistry";
const RUN_E2E = process.env.RUN_E2E === "1";
const USE_PUBLIC = process.env.E2E_USE_PUBLIC_CHAIN138 === "1";
const d = RUN_E2E && USE_PUBLIC ? describe : describe.skip;
const DEFAULT_PUBLIC_RPC = "https://rpc.public-0138.defi-oracle.io";
const EXPECTED_CHAIN_ID = 138n;
function getPublicRpcUrl(): string {
// If the caller set CHAIN_138_RPC_URL, honour it (matches how the
// orchestrator's own services pick up config); otherwise use the
// documented public endpoint.
return process.env.CHAIN_138_RPC_URL || DEFAULT_PUBLIC_RPC;
}
d("NotaryRegistry read-only round-trip against public Chain 138", () => {
let rpcUrl: string;
let provider: JsonRpcProvider;
beforeAll(() => {
rpcUrl = getPublicRpcUrl();
// staticNetwork=true skips the network discovery handshake every
// call; cacheTimeout=-1 disables the 250ms response cache so
// subsequent JSON-RPC calls see fresh data.
provider = new JsonRpcProvider(rpcUrl, undefined, {
staticNetwork: true,
cacheTimeout: -1,
});
});
it("resolves a network descriptor", async () => {
const net = await provider.getNetwork();
expect(net).toBeDefined();
expect(typeof net.chainId).toBe("bigint");
}, 30_000);
it("eth_chainId matches Chain 138", async () => {
const net = await provider.getNetwork();
expect(net.chainId).toBe(EXPECTED_CHAIN_ID);
}, 30_000);
it("eth_blockNumber returns a positive current height", async () => {
const blockNumber = await provider.getBlockNumber();
expect(blockNumber).toBeGreaterThan(0);
}, 30_000);
it("eth_getBlockByNumber returns a well-formed block", async () => {
const latest = await provider.getBlockNumber();
const block = await provider.getBlock(latest);
expect(block).not.toBeNull();
if (block) {
expect(block.number).toBe(latest);
expect(block.hash).toMatch(/^0x[0-9a-fA-F]{64}$/);
expect(typeof block.timestamp).toBe("number");
expect(block.timestamp).toBeGreaterThan(1_600_000_000);
}
}, 30_000);
it("reads plans(bytes32) if NOTARY_REGISTRY_ADDRESS is set", async () => {
const addr = process.env.NOTARY_REGISTRY_ADDRESS;
if (!addr) {
// Not a failure — this is the current CI state until the
// deployed NotaryRegistry address is published to the
// environment. Document it instead of failing.
expect(addr).toBeUndefined();
return;
}
const { abi } = compileNotaryRegistry();
const readOnly = new Contract(addr, abi, provider);
// Synthetic id — we expect an empty / zero record but the call
// itself must succeed (proves ABI matches deployed contract).
const syntheticKey = keccakId("e2e-public-read-only-" + Date.now());
const record = await readOnly.getFunction("plans")(syntheticKey);
// plans() returns (planHash, creator, registeredAt, finalizedAt, success, receiptHash)
expect(record).toBeDefined();
expect(Array.isArray(record) || typeof record === "object").toBe(true);
// Either a fresh key → zeros, or an already-used key — both are OK.
// We only assert the types match the tuple shape.
const [planHash, , registeredAt, finalizedAt] = record as readonly [
string, string, bigint, bigint, boolean, string,
];
expect(typeof planHash).toBe("string");
expect(typeof registeredAt).toBe("bigint");
expect(typeof finalizedAt).toBe("bigint");
// For a synthetic key, every field should be zero.
expect(planHash).toBe(ZeroHash);
expect(registeredAt).toBe(0n);
expect(finalizedAt).toBe(0n);
}, 60_000);
it("orchestrator notaryChain module mock-falls-back when signing key is absent", async () => {
const saved = {
rpc: process.env.CHAIN_138_RPC_URL,
addr: process.env.NOTARY_REGISTRY_ADDRESS,
pk: process.env.ORCHESTRATOR_PRIVATE_KEY,
};
// Point at the public RPC but leave the signing key unset.
process.env.CHAIN_138_RPC_URL = rpcUrl;
delete process.env.ORCHESTRATOR_PRIVATE_KEY;
try {
jest.resetModules();
const chain = await import("../../src/services/notaryChain");
const result = await chain.anchorPlan({
plan_id: "public-rpc-readonly-" + Date.now(),
steps: [],
created_at: new Date().toISOString(),
} as never);
// With no signer, isConfigured() returns false → mock path.
expect(result.mode).toBe("mock");
expect(result.planHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
expect(result.txHash).toBeUndefined();
} finally {
if (saved.rpc !== undefined) process.env.CHAIN_138_RPC_URL = saved.rpc;
else delete process.env.CHAIN_138_RPC_URL;
if (saved.addr !== undefined) process.env.NOTARY_REGISTRY_ADDRESS = saved.addr;
if (saved.pk !== undefined) process.env.ORCHESTRATOR_PRIVATE_KEY = saved.pk;
}
}, 30_000);
});

View File

@@ -1,173 +0,0 @@
/**
* End-to-end round-trip against a real EVM node.
*
* Spawns the ganache CLI as a child process on a random dev port,
* deploys NotaryRegistry.sol compiled via in-process solc, and
* exercises services/notaryChain.ts (`anchorPlan` + `finalizeAnchor`)
* against it via ethers v6. This closes the
* orchestrator-unit-tests-pass-but-the-adapter-to-reality-boundary-
* is-uncovered gap flagged in gap-analysis v2 §7.9 / §8.5 — PR Q's
* existing suite covers Postgres only.
*
* Gated on RUN_E2E=1 to stay out of the fast unit-test path. Runs on
* CI via the `orchestrator-e2e` job (see .github/workflows/ci.yml).
*/
import { spawn, type ChildProcess } from "child_process";
import { JsonRpcProvider, Wallet, ContractFactory, Contract } from "ethers";
import { compileNotaryRegistry } from "./helpers/compileNotaryRegistry";
const RUN_E2E = process.env.RUN_E2E === "1";
const d = RUN_E2E ? describe : describe.skip;
const DEPLOYER_PK = "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d";
async function waitForRpc(url: string, timeoutMs = 30_000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "eth_chainId", params: [], id: 1 }),
});
if (r.ok) return;
} catch {
/* not ready yet */
}
await new Promise((r) => setTimeout(r, 300));
}
throw new Error(`RPC did not come up within ${timeoutMs}ms: ${url}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type NotaryChainModule = typeof import("../../src/services/notaryChain");
d("NotaryRegistry chain round-trip (E2E)", () => {
let ganacheProc: ChildProcess;
let port: number;
let rpcUrl: string;
let contractAddress: string;
let chain: NotaryChainModule;
beforeAll(async () => {
port = 18545 + Math.floor(Math.random() * 1000);
rpcUrl = `http://127.0.0.1:${port}`;
ganacheProc = spawn(
"node_modules/.bin/ganache",
[
"--port",
String(port),
"--chain.chainId",
"1337",
"--wallet.accounts",
`${DEPLOYER_PK},1000000000000000000000`,
"--logging.quiet",
],
{ stdio: "pipe", cwd: process.cwd() },
);
await waitForRpc(rpcUrl);
const provider = new JsonRpcProvider(rpcUrl);
const wallet = new Wallet(DEPLOYER_PK, provider);
const { abi, bytecode } = compileNotaryRegistry();
// OZ v5 Ownable requires `initialOwner` in the constructor.
const factory = new ContractFactory(abi, bytecode, wallet);
const deployer = await wallet.getAddress();
const contract = (await factory.deploy(deployer)) as unknown as Contract;
await contract.waitForDeployment();
contractAddress = await contract.getAddress();
// Wire the service under test to this chain. Import after the env
// is set so the service's lazy loader picks it up.
process.env.CHAIN_138_RPC_URL = rpcUrl;
process.env.CHAIN_138_CHAIN_ID = "1337";
process.env.NOTARY_REGISTRY_ADDRESS = contractAddress;
process.env.ORCHESTRATOR_PRIVATE_KEY = DEPLOYER_PK;
jest.resetModules();
chain = await import("../../src/services/notaryChain");
}, 120_000);
afterAll(async () => {
if (ganacheProc && !ganacheProc.killed) {
ganacheProc.kill("SIGTERM");
await new Promise((r) => setTimeout(r, 300));
}
delete process.env.CHAIN_138_RPC_URL;
delete process.env.CHAIN_138_CHAIN_ID;
delete process.env.NOTARY_REGISTRY_ADDRESS;
delete process.env.ORCHESTRATOR_PRIVATE_KEY;
});
it("anchorPlan writes a PlanRegistered record on-chain", async () => {
const plan = {
plan_id: "e2e-plan-" + Date.now(),
steps: [],
created_at: new Date().toISOString(),
};
const expectedHash = chain.computePlanHash(plan as never);
const result = await chain.anchorPlan(plan as never);
expect(result.mode).toBe("chain");
expect(result.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
expect(result.blockNumber).toBeGreaterThan(0);
expect(result.planHash).toBe(expectedHash);
// Directly query the contract to prove the state transition landed.
const provider = new JsonRpcProvider(rpcUrl);
const { abi } = compileNotaryRegistry();
const readOnly = new Contract(contractAddress, abi, provider);
const stored = await readOnly.getFunction("plans")(chain.planIdToBytes32(plan.plan_id));
// plans(bytes32) → (planHash, creator, registeredAt, finalizedAt, success, receiptHash)
expect(stored[0]).toMatch(/^0x[0-9a-fA-F]{64}$/);
expect(Number(stored[2])).toBeGreaterThan(0); // registeredAt
expect(Number(stored[3])).toBe(0); // finalizedAt
}, 60_000);
it("finalizeAnchor writes a PlanFinalized record with a receipt hash", async () => {
const plan = {
plan_id: "e2e-finalize-" + Date.now(),
steps: [],
created_at: new Date().toISOString(),
};
await chain.anchorPlan(plan as never);
const result = await chain.finalizeAnchor(plan.plan_id, true);
expect(result.mode).toBe("chain");
expect(result.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
expect(result.receiptHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
expect(result.blockNumber).toBeGreaterThan(0);
}, 60_000);
it("anchorPlan falls back to mock when envs are cleared", async () => {
const saved = {
rpc: process.env.CHAIN_138_RPC_URL,
addr: process.env.NOTARY_REGISTRY_ADDRESS,
pk: process.env.ORCHESTRATOR_PRIVATE_KEY,
};
delete process.env.CHAIN_138_RPC_URL;
delete process.env.NOTARY_REGISTRY_ADDRESS;
delete process.env.ORCHESTRATOR_PRIVATE_KEY;
try {
jest.resetModules();
const mockOnly = await import("../../src/services/notaryChain");
const result = await mockOnly.anchorPlan({
plan_id: "mock-plan",
steps: [],
created_at: new Date().toISOString(),
} as never);
expect(result.mode).toBe("mock");
expect(result.txHash).toBeUndefined();
expect(result.planHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
} finally {
if (saved.rpc) process.env.CHAIN_138_RPC_URL = saved.rpc;
if (saved.addr) process.env.NOTARY_REGISTRY_ADDRESS = saved.addr;
if (saved.pk) process.env.ORCHESTRATOR_PRIVATE_KEY = saved.pk;
}
});
});

View File

@@ -1,178 +0,0 @@
/**
* E2E transaction lifecycle (gap-analysis v2 §7.8 / §10.8).
*
* Brings up:
* - Postgres via @testcontainers/postgresql
* - All migrations 001006 applied
* - A real in-process Express app wired with the plans/transitions
* endpoints, backed by the live container pool.
*
* Skipped unless RUN_E2E=1 and Docker is reachable. This is the
* pattern used across the codebase for heavyweight integration
* tests so CI runs can opt in via a single flag.
*
* NB: Chain-138 RPC, SWIFT gateway, and Redis are all mocked-local
* by default. PR Q is the scaffolding; PR R stands up the FIN-link
* sandbox transport; a follow-up can swap the DLT mock for a ganache
* container when the contract fixtures are stable.
*/
import { describe, it, expect, beforeAll, afterAll } from "@jest/globals";
import express from "express";
import request from "supertest";
const shouldRun = process.env.RUN_E2E === "1";
// Use describe.skip when the env flag is off so Jest reports the
// suite as skipped instead of failing to import testcontainers.
const d = shouldRun ? describe : describe.skip;
d("E2E transaction lifecycle (Postgres testcontainer)", () => {
let pgContainer: unknown;
let connectionString = "";
let app: express.Express;
beforeAll(async () => {
const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
const container = await new PostgreSqlContainer("postgres:15-alpine")
.withDatabase("ccflow_e2e")
.withUsername("ccflow")
.withPassword("ccflow")
.start();
pgContainer = container;
connectionString = container.getConnectionUri();
process.env.DATABASE_URL = connectionString;
process.env.SESSION_SECRET =
"e2e-session-secret-must-be-at-least-32-chars-long!";
process.env.NODE_ENV = "test";
// Import after env set so migrations/pool read the container URL.
const { getPool, query } = await import("../../src/db/postgres");
await query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`);
// schema.sql contains $$...$$ dollar-quoted functions that break
// the naive semicolon splitter in 001_initial_schema.ts. Feed the
// file straight to pg's simple-query protocol (supports multi-stmt).
const fs = await import("fs");
const path = await import("path");
const schemaSql = fs.readFileSync(
path.join(__dirname, "../../src/db/schema.sql"),
"utf-8",
);
const pool = getPool();
const client = await pool.connect();
try {
await client.query(schemaSql);
} finally {
client.release();
}
// Run the numbered migrations after schema.sql.
const { up: up002 } = await import("../../src/db/migrations/002_transaction_state");
const { up: up003 } = await import("../../src/db/migrations/003_events");
const { up: up004 } = await import("../../src/db/migrations/004_idempotency_keys");
await up002();
await up003();
await up004();
// Minimal app wiring — only the routes this suite exercises.
const { createPlan, getPlan } = await import("../../src/api/plans");
app = express();
app.use(express.json());
app.post("/api/plans", createPlan);
app.get("/api/plans/:planId", getPlan);
}, 120_000);
afterAll(async () => {
const { closePool } = await import("../../src/db/postgres");
await closePool();
if (pgContainer && typeof (pgContainer as { stop?: () => Promise<void> }).stop === "function") {
await (pgContainer as { stop: () => Promise<void> }).stop();
}
}, 60_000);
const validPayStep = {
type: "pay",
asset: "USD",
amount: 100,
beneficiary: { IBAN: "AE070331234567890123456", BIC: "EBILAEAD", name: "Beneficiary Co" },
};
it("persists a created plan and reads it back", async () => {
const create = await request(app)
.post("/api/plans")
.send({
creator: "0xtest-creator",
steps: [validPayStep],
})
.expect(201);
expect(create.body.plan_id).toBeDefined();
expect(create.body.plan_hash).toMatch(/^[0-9a-fA-F]{64}$/);
const read = await request(app)
.get(`/api/plans/${create.body.plan_id}`)
.expect(200);
expect(read.body.plan_id).toBe(create.body.plan_id);
}, 30_000);
it("publishes a signed event row via the live event bus", async () => {
const create = await request(app)
.post("/api/plans")
.send({
creator: "0xtest-creator-2",
steps: [validPayStep],
})
.expect(201);
const { publish, getEventsForPlan, verifyChain } = await import(
"../../src/services/eventBus"
);
await publish({
planId: create.body.plan_id,
type: "transaction.created",
actor: "e2e",
payload: { plan_hash: create.body.plan_hash },
});
await publish({
planId: create.body.plan_id,
type: "transaction.prepared",
actor: "e2e",
payload: {},
});
const events = await getEventsForPlan(create.body.plan_id);
expect(events).toHaveLength(2);
expect(events[0].prev_hash).toBeNull();
expect(events[1].prev_hash).toBe(events[0].signature);
const chain = await verifyChain(create.body.plan_id);
expect(chain.ok).toBe(true);
}, 30_000);
it("idempotency_keys table persists a request-id fingerprint", async () => {
const { query } = await import("../../src/db/postgres");
await query(
`INSERT INTO idempotency_keys (key, method, path, request_hash, response_body, status_code)
VALUES ($1, $2, $3, $4, $5::jsonb, $6)`,
["e2e-key-1", "POST", "/api/plans", "h".repeat(64), JSON.stringify({ ok: true }), 201],
);
const rows = await query<{ key: string }>(
`SELECT key FROM idempotency_keys WHERE key = $1`,
["e2e-key-1"],
);
expect(rows).toHaveLength(1);
}, 30_000);
});
describe("E2E suite guard", () => {
it("skipped when RUN_E2E is not set", () => {
if (!shouldRun) {
expect(shouldRun).toBe(false);
return;
}
expect(true).toBe(true);
});
});

View File

@@ -1,232 +0,0 @@
/**
* Unit tests for the Complete Credential (DBIS cc-*) adapters.
*
* All tests are headless — they either exercise the embedded mock /
* matrix or stub `fetch` directly. No network, no UI.
*/
import {
createCcIdentityClient,
loadControlsMatrix,
findControl,
type CcIdentityClient,
} from "../../src/services/completeCredential";
describe("completeCredential.createCcIdentityClient() — mock mode", () => {
let client: CcIdentityClient;
beforeAll(() => {
delete process.env.CC_IDENTITY_URL;
delete process.env.CC_IDENTITY_API_KEY;
client = createCcIdentityClient();
});
it("reports mock mode", () => {
expect(client.mode).toBe("mock");
});
it("returns ok for health()", async () => {
const h = await client.health();
expect(h.status).toBe("ok");
expect(h.service).toBe("cc-identity-core");
});
it("returns a ready response with persistence=false in mock", async () => {
const r = await client.ready();
expect(r.status).toBe("ok");
expect(r.persistence).toBe(false);
});
it("creates a subject with a uuid and defaulted tenant/entity", async () => {
const s = await client.createSubject({});
expect(s.subjectId).toMatch(/^[0-9a-f-]{36}$/);
expect(s.tenantId).toBe("tenant-demo");
expect(s.entityId).toBe("entity-demo");
});
it("passes tenant/entity through when provided", async () => {
const s = await client.createSubject({
tenantId: "t-acme",
entityId: "e-bank-1",
});
expect(s.tenantId).toBe("t-acme");
expect(s.entityId).toBe("e-bank-1");
});
});
describe("completeCredential.createCcIdentityClient() — live mode (stubbed fetch)", () => {
function makeFetch(
record: (url: string, init: RequestInit) => void,
responseBody: unknown,
status = 200,
): typeof fetch {
return (async (input: string | URL | Request, init?: RequestInit) => {
record(String(input), init ?? {});
return new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
}
it("reports live mode when baseUrl is set", () => {
const client = createCcIdentityClient({
baseUrl: "http://cc.example.test",
fetchImpl: makeFetch(() => undefined, { status: "ok", service: "x" }),
});
expect(client.mode).toBe("live");
});
it("hits GET /health", async () => {
const calls: string[] = [];
const client = createCcIdentityClient({
baseUrl: "http://cc.example.test",
fetchImpl: makeFetch(
(url) => {
calls.push(url);
},
{ status: "ok", service: "cc-identity-core" },
),
});
const h = await client.health();
expect(h.status).toBe("ok");
expect(calls[0]).toBe("http://cc.example.test/health");
});
it("posts to /v1/subjects with X-Correlation-Id + api key header", async () => {
const calls: { url: string; headers: Record<string, string>; body?: string }[] = [];
const client = createCcIdentityClient({
baseUrl: "http://cc.example.test",
apiKey: "k-1",
fetchImpl: makeFetch(
(url, init) => {
calls.push({
url,
headers: (init.headers ?? {}) as Record<string, string>,
body: init.body as string,
});
},
{
subjectId: "11111111-2222-3333-4444-555555555555",
tenantId: "t-1",
entityId: "e-1",
createdAt: "2026-01-01T00:00:00Z",
},
),
});
const s = await client.createSubject({ tenantId: "t-1", entityId: "e-1" }, "corr-42");
expect(s.subjectId).toContain("-");
expect(calls[0].url).toBe("http://cc.example.test/v1/subjects");
expect(calls[0].headers["X-API-Key"]).toBe("k-1");
expect(calls[0].headers["X-Correlation-Id"]).toBe("corr-42");
expect(JSON.parse(calls[0].body ?? "{}")).toEqual({
tenantId: "t-1",
entityId: "e-1",
});
});
it("auto-generates a correlation id when not provided", async () => {
const calls: Record<string, string>[] = [];
const client = createCcIdentityClient({
baseUrl: "http://cc.example.test",
fetchImpl: makeFetch(
(_url, init) => {
calls.push((init.headers ?? {}) as Record<string, string>);
},
{
subjectId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
tenantId: "t",
entityId: "e",
createdAt: "2026-01-01T00:00:00Z",
},
),
});
await client.createSubject({});
expect(calls[0]["X-Correlation-Id"]).toMatch(/^[0-9a-f-]{36}$/);
});
it("throws a descriptive error on non-2xx", async () => {
const client = createCcIdentityClient({
baseUrl: "http://cc.example.test",
fetchImpl: makeFetch(() => undefined, { error: "boom" }, 500),
});
await expect(client.health()).rejects.toThrow(/HTTP 500/);
});
});
describe("completeCredential.loadControlsMatrix() — embedded mode", () => {
beforeAll(() => {
delete process.env.CC_CONTROLS_MATRIX_URL;
});
it("returns the embedded v0 matrix when no URL is set", async () => {
const m = await loadControlsMatrix();
expect(m.source).toBe("embedded");
expect(m.version).toBe(0);
expect(m.domains.length).toBeGreaterThan(0);
});
it("exposes expected control ids", async () => {
const m = await loadControlsMatrix();
const ids = m.domains.flatMap((d) => d.controls.map((c) => c.id));
expect(ids).toEqual(expect.arrayContaining(["IDP-001", "PAY-001", "AUD-001", "REG-001"]));
});
it("findControl() resolves by id", async () => {
const m = await loadControlsMatrix();
const c = findControl(m, "PAY-001");
expect(c?.title).toContain("PAN");
});
it("findControl() returns undefined for unknown ids", async () => {
const m = await loadControlsMatrix();
expect(findControl(m, "NOPE-999")).toBeUndefined();
});
});
describe("completeCredential.loadControlsMatrix() — remote mode", () => {
function makeFetch(responseBody: unknown, status = 200): typeof fetch {
return (async () =>
new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
}
it("fetches and normalises a JSON matrix", async () => {
const matrix = await loadControlsMatrix({
url: "http://cc.example.test/controls/matrix/v0.json",
fetchImpl: makeFetch({
version: 1,
domains: [
{
id: "extra",
controls: [
{
id: "X-001",
title: "Extra",
evidence_type: "doc_review",
owner_team: "ops",
frequency: "monthly",
},
],
},
],
}),
});
expect(matrix.source).toBe("remote");
expect(matrix.version).toBe(1);
expect(matrix.domains[0].controls[0].evidenceType).toBe("doc_review");
expect(matrix.domains[0].controls[0].ownerTeam).toBe("ops");
});
it("throws on non-2xx", async () => {
await expect(
loadControlsMatrix({
url: "http://cc.example.test/nope",
fetchImpl: makeFetch({}, 404),
}),
).rejects.toThrow(/HTTP 404/);
});
});

View File

@@ -1,198 +0,0 @@
/**
* Unit tests for the dbis_core HTTP client adapter.
*
* Covers both provider-switch legs:
* - `createDbisCoreClient()` with DBIS_CORE_URL unset → mock mode.
* - `createDbisCoreClient({ baseUrl, fetchImpl })` → live mode, with
* a stub `fetch` so tests never hit the network.
*/
import {
createDbisCoreClient,
type DbisCoreClient,
} from "../../src/services/dbisCore";
describe("dbisCore.createDbisCoreClient() — mock mode", () => {
let client: DbisCoreClient;
beforeAll(() => {
delete process.env.DBIS_CORE_URL;
delete process.env.DBIS_CORE_API_KEY;
client = createDbisCoreClient();
});
it("reports mock mode", () => {
expect(client.mode).toBe("mock");
});
it("returns a balance shaped like upstream", async () => {
const b = await client.getAccountBalance("acct-1");
expect(b.accountId).toBe("acct-1");
expect(typeof b.available).toBe("string");
expect(typeof b.held).toBe("string");
expect(b.currency).toBeDefined();
});
it("returns a plausible route", async () => {
const r = await client.findSettlementRoute({
sourceBankId: "src",
destinationBankId: "dst",
amount: "100",
currencyCode: "USD",
});
expect(r.routeId).toContain("src");
expect(r.hops.length).toBeGreaterThan(0);
expect(r.estimatedFeeBps).toBeGreaterThanOrEqual(0);
});
it("settles atomically with a deterministic id", async () => {
const s = await client.atomicSettle({
routeId: "r1",
sourceAccountId: "a",
destinationAccountId: "b",
amount: "1",
currencyCode: "USD",
reference: "ref-1",
});
expect(s.status).toBe("settled");
expect(s.settlementId).toContain("ref-1");
expect(s.completedAt).toBeDefined();
});
it("returns an allow decision by default from ARI", async () => {
const d = await client.requestAriDecision({
txId: "tx-1",
amount: "1",
currencyCode: "USD",
creator: "0xdead",
});
expect(d.outcome).toBe("allow");
expect(d.txId).toBe("tx-1");
expect(d.riskScore).toBeLessThan(1);
});
it("accepts a pacs008 dispatch and echoes the messageId", async () => {
const r = await client.dispatchPacs008({
messageId: "msg-1",
creationDateTime: "2026-01-01T00:00:00Z",
debtor: { name: "Acme", bic: "ACMEUS33", account: "1" },
creditor: { name: "Widget", bic: "WDGTGB22", account: "2" },
amount: "100",
currencyCode: "USD",
});
expect(r.status).toBe("accepted");
expect(r.messageId).toBe("msg-1");
});
it("returns a settled status from a synthetic settlementId", async () => {
const s = await client.getSettlementStatus("stlm-99");
expect(s.status).toBe("settled");
expect(s.legs.length).toBeGreaterThan(0);
});
});
describe("dbisCore.createDbisCoreClient() — live mode (stubbed fetch)", () => {
function makeFetch(
record: (url: string, init: RequestInit) => void,
responseBody: unknown,
status = 200,
): typeof fetch {
return (async (input: string | URL | Request, init?: RequestInit) => {
record(String(input), init ?? {});
return new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
}
it("reports live mode when baseUrl is set", () => {
const client = createDbisCoreClient({
baseUrl: "http://dbis.example.test",
fetchImpl: makeFetch(
() => undefined,
{ accountId: "a", currency: "USD", available: "0", held: "0", asOf: "" },
),
});
expect(client.mode).toBe("live");
});
it("hits GET /api/accounts/:id/balance with the API key header", async () => {
const calls: { url: string; headers: Record<string, string>; method?: string }[] = [];
const client = createDbisCoreClient({
baseUrl: "http://dbis.example.test",
apiKey: "k-secret",
fetchImpl: makeFetch(
(url, init) => {
calls.push({
url,
method: init.method,
headers: (init.headers ?? {}) as Record<string, string>,
});
},
{
accountId: "a42",
currency: "USD",
available: "500",
held: "10",
asOf: "2026-01-01T00:00:00Z",
},
),
});
const b = await client.getAccountBalance("a42");
expect(b.available).toBe("500");
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe("http://dbis.example.test/api/accounts/a42/balance");
expect(calls[0].method).toBe("GET");
expect(calls[0].headers["X-API-Key"]).toBe("k-secret");
});
it("posts a route request and parses the structured response", async () => {
const client = createDbisCoreClient({
baseUrl: "http://dbis.example.test/",
fetchImpl: makeFetch(
() => undefined,
{
routeId: "R1",
hops: [{ bankId: "A", latencyMs: 1, feeBps: 2 }],
estimatedLatencyMs: 10,
estimatedFeeBps: 2,
},
),
});
const r = await client.findSettlementRoute({
sourceBankId: "A",
destinationBankId: "B",
amount: "1",
currencyCode: "USD",
});
expect(r.routeId).toBe("R1");
expect(r.estimatedFeeBps).toBe(2);
});
it("throws a descriptive error on non-2xx", async () => {
const client = createDbisCoreClient({
baseUrl: "http://dbis.example.test",
fetchImpl: makeFetch(() => undefined, { error: "denied" }, 403),
});
await expect(client.getAccountBalance("a1")).rejects.toThrow(/HTTP 403/);
});
it("encodes path parameters safely", async () => {
const calls: string[] = [];
const client = createDbisCoreClient({
baseUrl: "http://dbis.example.test",
fetchImpl: makeFetch(
(url) => {
calls.push(url);
},
{ settlementId: "x", status: "settled", legs: [], lastUpdated: "" },
),
});
await client.getSettlementStatus("weird/id with space");
expect(calls[0]).toBe(
"http://dbis.example.test/api/isn/settlements/weird%2Fid%20with%20space",
);
});
});

View File

@@ -1,170 +0,0 @@
import { describe, it, expect, beforeEach } from "@jest/globals";
import express from "express";
import request from "supertest";
import {
buildSandboxRouter,
recordDispatch,
advance,
rejectMessage,
getMessage,
listMessages,
resetSandboxForTests,
finSignature,
} from "../../src/services/finLink/sandbox";
import {
createInProcessFinLinkClient,
createHttpFinLinkClient,
} from "../../src/services/finLink/client";
describe("FIN-link sandbox (gap-analysis v2 §7.1 / §10.6)", () => {
beforeEach(() => {
resetSandboxForTests();
});
describe("lifecycle (in-memory)", () => {
it("assigns a FIN reference and records state received", () => {
const msg = recordDispatch({
messageType: "MT760",
payload: "MT760 payload",
planId: "plan-1",
});
expect(msg.reference).toMatch(/^FIN-[0-9A-F]{12}$/);
expect(msg.state).toBe("received");
expect(msg.stateHistory).toHaveLength(1);
expect(msg.planId).toBe("plan-1");
});
it("advances deterministically: received -> acknowledged -> accepted -> settled", async () => {
const msg = recordDispatch({ messageType: "pacs.009", payload: "<pacs.009/>" });
expect((await advance(msg.reference))!.state).toBe("acknowledged");
expect((await advance(msg.reference))!.state).toBe("accepted");
expect((await advance(msg.reference))!.state).toBe("settled");
expect((await advance(msg.reference))!.state).toBe("settled"); // terminal
const final = getMessage(msg.reference)!;
expect(final.stateHistory.map((h) => h.state)).toEqual([
"received",
"acknowledged",
"accepted",
"settled",
]);
});
it("supports rejection and stops lifecycle progression", async () => {
const msg = recordDispatch({ messageType: "MT202", payload: "MT202 payload" });
const rejected = rejectMessage(msg.reference, "bad coordinates")!;
expect(rejected.state).toBe("rejected");
const afterAdvance = await advance(msg.reference);
expect(afterAdvance!.state).toBe("rejected");
});
it("listMessages filters by planId", () => {
recordDispatch({ messageType: "MT760", payload: "a", planId: "plan-a" });
recordDispatch({ messageType: "MT760", payload: "b", planId: "plan-b" });
recordDispatch({ messageType: "MT760", payload: "c", planId: "plan-a" });
expect(listMessages().length).toBe(3);
expect(listMessages({ planId: "plan-a" }).length).toBe(2);
});
});
describe("signature", () => {
it("produces a stable 64-char hex HMAC", () => {
const sig = finSignature("hello");
expect(sig).toMatch(/^[0-9a-f]{64}$/);
expect(finSignature("hello")).toBe(sig);
expect(finSignature("world")).not.toBe(sig);
});
});
describe("HTTP router", () => {
const app = express();
app.use("/fin", buildSandboxRouter());
beforeEach(() => resetSandboxForTests());
it("POST /fin/dispatch returns 202 + reference", async () => {
const resp = await request(app)
.post("/fin/dispatch")
.send({ messageType: "MT760", payload: "mt760", planId: "plan-x" })
.expect(202);
expect(resp.body.reference).toMatch(/^FIN-/);
expect(resp.body.state).toBe("received");
});
it("POST /fin/dispatch 400s on missing payload", async () => {
await request(app)
.post("/fin/dispatch")
.send({ messageType: "MT760" })
.expect(400);
});
it("POST /fin/advance/:ref walks through lifecycle", async () => {
const d = await request(app)
.post("/fin/dispatch")
.send({ messageType: "pacs.009", payload: "<pacs.009/>" })
.expect(202);
const ref = d.body.reference;
const a1 = await request(app).post(`/fin/advance/${ref}`).expect(200);
expect(a1.body.state).toBe("acknowledged");
const a2 = await request(app).post(`/fin/advance/${ref}`).expect(200);
expect(a2.body.state).toBe("accepted");
const a3 = await request(app).post(`/fin/advance/${ref}`).expect(200);
expect(a3.body.state).toBe("settled");
});
it("GET /fin/messages?planId=... filters", async () => {
await request(app)
.post("/fin/dispatch")
.send({ messageType: "MT760", payload: "a", planId: "p1" });
await request(app)
.post("/fin/dispatch")
.send({ messageType: "MT760", payload: "b", planId: "p2" });
const r = await request(app).get("/fin/messages?planId=p1").expect(200);
expect(r.body.messages).toHaveLength(1);
expect(r.body.messages[0].planId).toBe("p1");
});
it("GET /fin/messages/:ref returns 404 for unknown", async () => {
await request(app).get("/fin/messages/FIN-UNKNOWN").expect(404);
});
});
describe("client", () => {
beforeEach(() => resetSandboxForTests());
it("createInProcessFinLinkClient dispatches and reads back", async () => {
const client = await createInProcessFinLinkClient();
const ack = await client.dispatch({
messageType: "MT760",
payload: "mt760",
planId: "plan-ip",
});
expect(ack.reference).toMatch(/^FIN-/);
const msg = await client.getMessage(ack.reference);
expect(msg?.planId).toBe("plan-ip");
});
it("createHttpFinLinkClient hits the live router", async () => {
const app = express();
app.use("/fin", buildSandboxRouter());
const server = app.listen(0);
try {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const client = createHttpFinLinkClient(`http://127.0.0.1:${port}/fin`);
const ack = await client.dispatch({
messageType: "pacs.009",
payload: "<pacs.009/>",
planId: "plan-http",
});
expect(ack.reference).toMatch(/^FIN-/);
const msg = await client.getMessage(ack.reference);
expect(msg?.messageType).toBe("pacs.009");
const missing = await client.getMessage("FIN-DOES-NOT-EXIST");
expect(missing).toBeNull();
} finally {
server.close();
}
});
});
});

View File

@@ -1,177 +0,0 @@
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
import type { Request, Response, NextFunction } from "express";
type Row = {
method: string;
path: string;
key: string;
request_hash: string;
status_code: number;
response_body: unknown;
};
const store = new Map<string, Row>();
jest.mock("../../src/db/postgres", () => ({
query: async (sql: string, params: unknown[] = []) => {
if (sql.startsWith("SELECT request_hash")) {
const [method, path, key] = params as [string, string, string];
const row = store.get(`${method}|${path}|${key}`);
return row ? [row] : [];
}
if (sql.startsWith("INSERT INTO idempotency_keys")) {
const [method, path, key, request_hash, status_code, body] = params as [
string, string, string, string, number, string,
];
const k = `${method}|${path}|${key}`;
if (!store.has(k)) {
store.set(k, {
method,
path,
key,
request_hash,
status_code,
response_body: JSON.parse(body),
});
}
return [];
}
return [];
},
}));
import { idempotencyMiddleware, IDEMPOTENCY_HEADER } from "../../src/middleware/idempotency";
function makeReqRes(overrides: {
header?: string;
method?: string;
baseUrl?: string;
path?: string;
body?: unknown;
}) {
const req = {
method: overrides.method ?? "POST",
baseUrl: overrides.baseUrl ?? "",
path: overrides.path ?? "/api/plans",
body: overrides.body ?? { a: 1 },
header(name: string) {
return name.toLowerCase() === IDEMPOTENCY_HEADER ? overrides.header : undefined;
},
} as unknown as Request;
const captured: { status?: number; body?: unknown; headers: Record<string, string> } = {
headers: {},
};
const res: Partial<Response> = {
statusCode: 200,
status(code: number) {
this.statusCode = code;
captured.status = code;
return this as Response;
},
json(body: unknown) {
captured.body = body;
if (captured.status === undefined) captured.status = this.statusCode;
return this as Response;
},
setHeader(name: string, value: string | number | readonly string[]) {
captured.headers[name] = String(value);
return this as Response;
},
};
return { req, res: res as Response, captured };
}
describe("Idempotency middleware", () => {
beforeEach(() => {
store.clear();
});
it("skips when no Idempotency-Key header is set", async () => {
const { req, res } = makeReqRes({});
const next = jest.fn() as unknown as NextFunction;
await idempotencyMiddleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
});
it("rejects malformed keys with 400", async () => {
const { req, res, captured } = makeReqRes({ header: "short" });
const next = jest.fn() as unknown as NextFunction;
await idempotencyMiddleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(captured.status).toBe(400);
expect((captured.body as { error: string }).error).toBe("idempotency_key_invalid");
});
it("caches 2xx responses on first call and replays on second", async () => {
const key = "ABC12345_test-key";
const first = makeReqRes({ header: key });
const next1 = jest.fn() as unknown as NextFunction;
await idempotencyMiddleware(first.req, first.res, next1);
expect(next1).toHaveBeenCalledTimes(1);
// Simulate handler sending JSON response
first.res.status(201);
first.res.json({ plan_id: "p-1", created: true });
// Let the fire-and-forget INSERT microtask flush
await new Promise((r) => setImmediate(r));
const second = makeReqRes({ header: key });
const next2 = jest.fn() as unknown as NextFunction;
await idempotencyMiddleware(second.req, second.res, next2);
expect(next2).not.toHaveBeenCalled();
expect(second.captured.status).toBe(201);
expect(second.captured.body).toEqual({ plan_id: "p-1", created: true });
expect(second.captured.headers["Idempotent-Replayed"]).toBe("true");
});
it("rejects reuse with a different body as 422", async () => {
const key = "ABC12345_test-key";
const first = makeReqRes({ header: key, body: { a: 1 } });
const next1 = jest.fn() as unknown as NextFunction;
await idempotencyMiddleware(first.req, first.res, next1);
first.res.status(200);
first.res.json({ ok: true });
await new Promise((r) => setImmediate(r));
const second = makeReqRes({ header: key, body: { a: 2 } });
const next2 = jest.fn() as unknown as NextFunction;
await idempotencyMiddleware(second.req, second.res, next2);
expect(next2).not.toHaveBeenCalled();
expect(second.captured.status).toBe(422);
expect((second.captured.body as { error: string }).error).toBe("idempotency_key_reused");
});
it("does NOT cache non-2xx responses (retryable)", async () => {
const key = "ABC12345_test-key";
const first = makeReqRes({ header: key });
await idempotencyMiddleware(first.req, first.res, jest.fn() as unknown as NextFunction);
first.res.status(500);
first.res.json({ error: "boom" });
await new Promise((r) => setImmediate(r));
// Retry should go through (no replay)
const second = makeReqRes({ header: key });
const next2 = jest.fn() as unknown as NextFunction;
await idempotencyMiddleware(second.req, second.res, next2);
expect(next2).toHaveBeenCalledTimes(1);
});
it("scopes by (method, path, key)", async () => {
const key = "ABC12345_test-key";
const createPlan = makeReqRes({ header: key, path: "/api/plans" });
await idempotencyMiddleware(createPlan.req, createPlan.res, jest.fn() as unknown as NextFunction);
createPlan.res.status(201);
createPlan.res.json({ plan_id: "p-1" });
await new Promise((r) => setImmediate(r));
// Same key on a different path: should pass through, not replay
const execute = makeReqRes({ header: key, path: "/api/plans/p-1/execute" });
const nextExec = jest.fn() as unknown as NextFunction;
await idempotencyMiddleware(execute.req, execute.res, nextExec);
expect(nextExec).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,284 +0,0 @@
import { describe, it, expect } from "@jest/globals";
import {
canonicalize,
hashObligationTerms,
validateObligationTerms,
evaluateClauses,
evaluateCommit,
evaluateAbort,
buildIssueInstrumentObligation,
type ObligationTerms,
} from "../../src/services/obligations";
import { evaluateCondition, resolvePath } from "../../src/services/obligations/evaluator";
describe("Obligation layer (gap-analysis v2 §4.1)", () => {
const instrument = {
applicant: "ACME Corp",
issuingBankBIC: "CHASUS33",
beneficiaryBankBIC: "EBILAEAD",
beneficiaryName: "Acme Beneficiary Ltd",
beneficiaryAccount: "AE070331234567890123456",
amount: 1_000_000,
currency: "USD",
tenor: "1Y",
expiryDate: "2026-12-31",
placeOfPresentation: "Dubai",
governingLaw: "URDG 758",
templateRef: "emirates-islamic-sblc-v3",
templateHash:
"a".repeat(64),
};
const authorizedParticipants = [
{ role: "coordinator" as const, actorId: "actor-1" },
{ role: "approver" as const, actorId: "actor-2" },
{ role: "releaser" as const, actorId: "actor-3" },
{ role: "validator" as const, actorId: "actor-4" },
{ role: "exception_manager" as const, actorId: "actor-5" },
];
describe("canonicalize()", () => {
it("sorts object keys at every depth", () => {
const a = canonicalize({ b: 1, a: { d: 2, c: 3 } });
const b = canonicalize({ a: { c: 3, d: 2 }, b: 1 });
expect(a).toBe(b);
expect(a).toBe('{"a":{"c":3,"d":2},"b":1}');
});
it("preserves array order", () => {
expect(canonicalize({ x: [3, 1, 2] })).toBe('{"x":[3,1,2]}');
});
it("handles null and nested arrays of objects", () => {
expect(
canonicalize({ a: null, b: [{ y: 2, x: 1 }, { z: 3 }] }),
).toBe('{"a":null,"b":[{"x":1,"y":2},{"z":3}]}');
});
});
describe("hashObligationTerms()", () => {
const terms = buildIssueInstrumentObligation({
instrument,
payor: "ACME Corp",
payee: "Acme Beneficiary Ltd",
authorizedParticipants,
});
it("produces a 64-char hex hash", () => {
expect(hashObligationTerms(terms)).toMatch(/^[0-9a-f]{64}$/);
});
it("is insensitive to key ordering", () => {
const shuffled: ObligationTerms = {
...terms,
consideration: {
payee: terms.consideration.payee,
currency: terms.consideration.currency,
amount: terms.consideration.amount,
payor: terms.consideration.payor,
},
};
expect(hashObligationTerms(shuffled)).toBe(hashObligationTerms(terms));
});
it("changes when any field mutates", () => {
const mutated: ObligationTerms = {
...terms,
consideration: { ...terms.consideration, amount: 999 },
};
expect(hashObligationTerms(mutated)).not.toBe(hashObligationTerms(terms));
});
});
describe("validateObligationTerms()", () => {
const valid = buildIssueInstrumentObligation({
instrument,
payor: "A",
payee: "B",
authorizedParticipants,
});
it("accepts a well-formed obligation", () => {
expect(validateObligationTerms(valid).ok).toBe(true);
});
it("rejects non-object input", () => {
expect(validateObligationTerms(null).ok).toBe(false);
expect(validateObligationTerms("nope").ok).toBe(false);
});
it("flags missing consideration fields", () => {
const bad = {
...valid,
consideration: { payor: "A", payee: "B", currency: "usd", amount: -5 },
};
const r = validateObligationTerms(bad);
expect(r.ok).toBe(false);
expect(r.errors).toEqual(
expect.arrayContaining([
expect.stringContaining("ISO-4217"),
expect.stringContaining("amount"),
]),
);
});
it("flags bad template hash", () => {
const bad = {
...valid,
governingDocuments: [
{ templateRef: "t", templateHash: "not-a-hash" },
],
};
const r = validateObligationTerms(bad);
expect(r.ok).toBe(false);
expect(r.errors.some((e) => e.includes("hex SHA-256"))).toBe(true);
});
it("flags empty authorizedParticipants[].role", () => {
const bad = {
...valid,
authorizedParticipants: [{ actorId: "x" }],
};
const r = validateObligationTerms(bad);
expect(r.ok).toBe(false);
});
});
describe("evaluator", () => {
it("resolvePath handles dotted + indexed paths", () => {
const ctx = { plan: { steps: [{ type: "pay" }, { type: "issueInstrument" }] } };
expect(resolvePath("plan.steps[1].type", ctx)).toBe("issueInstrument");
expect(resolvePath("plan.missing.x", ctx)).toBeUndefined();
});
it("evaluates all/any/not combinators", () => {
const ctx = { a: 1, b: 2 };
expect(
evaluateCondition(
{
all: [
{ path: "a", op: "eq", value: 1 },
{ path: "b", op: "gt", value: 1 },
],
},
ctx,
),
).toBe(true);
expect(
evaluateCondition(
{
any: [
{ path: "a", op: "eq", value: 99 },
{ path: "b", op: "gt", value: 1 },
],
},
ctx,
),
).toBe(true);
expect(
evaluateCondition({ not: { path: "a", op: "eq", value: 2 } }, ctx),
).toBe(true);
});
it("matches regex operator safely (no eval)", () => {
expect(
evaluateCondition(
{ path: "h", op: "matches", value: "^0x[0-9a-f]{4}$" },
{ h: "0xbeef" },
),
).toBe(true);
expect(
evaluateCondition(
{ path: "h", op: "matches", value: "^0x[0-9a-f]{4}$" },
{ h: "0xBEEFG" },
),
).toBe(false);
});
});
describe("evaluateClauses / evaluateCommit / evaluateAbort", () => {
const terms = buildIssueInstrumentObligation({
instrument,
payor: "ACME Corp",
payee: "Acme Beneficiary Ltd",
authorizedParticipants,
});
const passingCtx = {
state: "VALIDATING",
dlt: { tx_hash: "0x" + "b".repeat(64) },
bank: { iso_message_id: "MSG-1" },
exceptions: { active: [] },
instrument: { template_hash: instrument.templateHash, dispatched: true },
payment: {
amount: instrument.amount,
currency: instrument.currency,
failed: false,
},
};
it("evaluateCommit returns ok=true when all commit clauses pass", () => {
const r = evaluateCommit(terms, passingCtx);
expect(r.ok).toBe(true);
expect(r.results.every((x) => x.ok)).toBe(true);
});
it("evaluateCommit returns ok=false with per-clause reasons on failure", () => {
const badCtx = { ...passingCtx, dlt: { tx_hash: "not-hex" } };
const r = evaluateCommit(terms, badCtx);
expect(r.ok).toBe(false);
const failing = r.results.find((x) => !x.ok);
expect(failing?.clauseId).toBe("commit.dlt_tx_hash");
expect(failing?.failureReason).toBeTruthy();
});
it("evaluateAbort fires when an active exception exists", () => {
const ctx = {
...passingCtx,
exceptions: { active: [{ kind: "timeout" }] },
};
const r = evaluateAbort(terms, ctx);
expect(r.ok).toBe(true);
expect(r.results.find((x) => x.clauseId === "abort.exception_raised")?.ok).toBe(
true,
);
});
it("evaluateClauses surfaces evaluator errors without throwing", () => {
const bogus = [
{
id: "bogus",
description: "bad regex",
binds: "both" as const,
assert: { path: "h", op: "matches" as const, value: "[" }, // invalid regex
},
];
const r = evaluateClauses(bogus, { h: "x" });
expect(r.ok).toBe(false);
expect(r.results[0].failureReason).toBeTruthy();
});
});
describe("buildIssueInstrumentObligation()", () => {
it("binds the instrument template hash into governingDocuments", () => {
const terms = buildIssueInstrumentObligation({
instrument,
payor: "A",
payee: "B",
authorizedParticipants,
});
expect(terms.governingDocuments[0].templateHash).toBe(instrument.templateHash);
expect(terms.governingDocuments[0].governingLaw).toBe("URDG 758");
});
it("validates cleanly", () => {
const terms = buildIssueInstrumentObligation({
instrument,
payor: "A",
payee: "B",
authorizedParticipants,
});
expect(validateObligationTerms(terms).ok).toBe(true);
});
});
});

View File

@@ -1,245 +0,0 @@
/**
* PR P — Pluggable Rules Engine (gap-analysis v2 §5.2 partial).
*/
import { describe, it, expect, beforeEach } from "@jest/globals";
import {
evaluate,
evaluateCondition,
getRuleSet,
BUILTIN_PRECONDITIONS,
BUILTIN_COMMIT,
__resetRulesCacheForTests,
type RuleSet,
} from "../../src/services/rulesEngine";
describe("rulesEngine — primitive operators", () => {
it("eq / neq / gt / gte / lt / lte", () => {
expect(
evaluateCondition({ path: "a", op: "eq", value: 1 }, { a: 1 }),
).toBe(true);
expect(
evaluateCondition({ path: "a", op: "neq", value: 1 }, { a: 2 }),
).toBe(true);
expect(
evaluateCondition({ path: "a", op: "gt", value: 1 }, { a: 2 }),
).toBe(true);
expect(
evaluateCondition({ path: "a", op: "lte", value: 3 }, { a: 3 }),
).toBe(true);
});
it("in / not_in / exists / matches", () => {
expect(
evaluateCondition(
{ path: "role", op: "in", value: ["approver", "releaser"] },
{ role: "approver" },
),
).toBe(true);
expect(
evaluateCondition(
{ path: "role", op: "not_in", value: ["approver"] },
{ role: "operator" },
),
).toBe(true);
expect(
evaluateCondition({ path: "x", op: "exists" }, { x: 0 }),
).toBe(true);
expect(
evaluateCondition(
{ path: "hash", op: "matches", value: "^0x[0-9a-f]+$" },
{ hash: "0xabc" },
),
).toBe(true);
});
it("length_gte / length_lte work on arrays and strings", () => {
expect(
evaluateCondition({ path: "a", op: "length_gte", value: 2 }, { a: [1, 2] }),
).toBe(true);
expect(
evaluateCondition({ path: "a", op: "length_lte", value: 5 }, { a: "abcd" }),
).toBe(true);
});
it("dotted + indexed path resolution", () => {
expect(
evaluateCondition(
{ path: "plan.steps[1].type", op: "eq", value: "pay" },
{ plan: { steps: [{ type: "issue" }, { type: "pay" }] } },
),
).toBe(true);
});
});
describe("rulesEngine — combinators", () => {
const ctx = { role: "approver", amount: 1000 };
it("all (AND) — every child must pass", () => {
expect(
evaluateCondition(
{
all: [
{ path: "role", op: "eq", value: "approver" },
{ path: "amount", op: "gt", value: 500 },
],
},
ctx,
),
).toBe(true);
expect(
evaluateCondition(
{
all: [
{ path: "role", op: "eq", value: "approver" },
{ path: "amount", op: "gt", value: 5000 },
],
},
ctx,
),
).toBe(false);
});
it("any (OR) — at least one child must pass", () => {
expect(
evaluateCondition(
{
any: [
{ path: "role", op: "eq", value: "releaser" },
{ path: "amount", op: "gt", value: 500 },
],
},
ctx,
),
).toBe(true);
});
it("not — inverts the child", () => {
expect(
evaluateCondition(
{ not: { path: "role", op: "eq", value: "releaser" } },
ctx,
),
).toBe(true);
});
});
describe("rulesEngine — evaluate() and failure reporting", () => {
const ruleSet: RuleSet = {
id: "test.rs",
rules: [
{
id: "amount_positive",
description: "amount must be > 0",
assert: { path: "amount", op: "gt", value: 0 },
},
{
id: "role_listed",
description: "role must be in the allowed list",
assert: {
path: "role",
op: "in",
value: ["approver", "releaser", "operator"],
},
},
{
id: "warning_only",
description: "low amount warning",
severity: "warn",
assert: { path: "amount", op: "gte", value: 10_000 },
},
],
};
it("returns ok=true when all error-severity rules pass", () => {
const res = evaluate(ruleSet, { amount: 1000, role: "approver" });
expect(res.ok).toBe(true);
// warn still reported even though ok=true
expect(res.failures.some((f) => f.ruleId === "warning_only")).toBe(true);
expect(res.failures.every((f) => f.severity === "warn")).toBe(true);
});
it("returns ok=false with error failure when a blocking rule fails", () => {
const res = evaluate(ruleSet, { amount: -1, role: "approver" });
expect(res.ok).toBe(false);
const amountFail = res.failures.find((f) => f.ruleId === "amount_positive");
expect(amountFail?.severity).toBe("error");
});
it("'when' gates a rule — false when-clause skips the assert", () => {
const guarded: RuleSet = {
id: "guarded.rs",
rules: [
{
id: "kyc_if_present",
when: { path: "compliance", op: "exists" },
assert: { path: "compliance.kyc", op: "eq", value: "ok" },
},
],
};
expect(evaluate(guarded, {}).ok).toBe(true);
expect(evaluate(guarded, { compliance: { kyc: "ok" } }).ok).toBe(true);
expect(evaluate(guarded, { compliance: { kyc: "fail" } }).ok).toBe(false);
});
});
describe("rulesEngine — built-in rule sets", () => {
it("preconditions: pay step + non-empty participants passes", () => {
const res = evaluate(BUILTIN_PRECONDITIONS, {
plan: { steps: [{ type: "pay" }] },
participants: [{ id: "p1" }],
});
expect(res.ok).toBe(true);
});
it("preconditions: missing pay step fails", () => {
const res = evaluate(BUILTIN_PRECONDITIONS, {
plan: { steps: [{ type: "issueInstrument" }] },
participants: [{ id: "p1" }],
});
expect(res.ok).toBe(false);
expect(res.failures.some((f) => f.ruleId === "plan.pay_step_present")).toBe(
true,
);
});
it("commit: VALIDATING + matching refs + no exceptions passes", () => {
const res = evaluate(BUILTIN_COMMIT, {
state: "VALIDATING",
dlt: { txHash: `0x${"a".repeat(64)}` },
bank: { isoMessageId: "MSG-1" },
exceptions: { active: [] },
});
expect(res.ok).toBe(true);
});
it("commit: state != VALIDATING blocks", () => {
const res = evaluate(BUILTIN_COMMIT, {
state: "EXECUTING",
dlt: { txHash: `0x${"a".repeat(64)}` },
bank: { isoMessageId: "MSG-1" },
exceptions: { active: [] },
});
expect(res.ok).toBe(false);
expect(res.failures.some((f) => f.ruleId === "state.is_validating")).toBe(
true,
);
});
});
describe("rulesEngine — pluggable loading", () => {
beforeEach(() => {
__resetRulesCacheForTests();
delete process.env.RULES_FILE;
});
it("returns built-ins when RULES_FILE is unset", () => {
expect(getRuleSet(BUILTIN_PRECONDITIONS.id).rules.length).toBeGreaterThan(0);
expect(getRuleSet(BUILTIN_COMMIT.id).rules.length).toBeGreaterThan(0);
});
it("returns an empty rule set for unknown ids (no throw)", () => {
const rs = getRuleSet("nonexistent");
expect(rs.rules).toEqual([]);
});
});

3433
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
{
"name": "transaction-builder",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@xyflow/react": "^12.10.2",
"ethers": "^6.16.0",
"lucide-react": "^1.8.0",
"playwright": "^1.59.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -1,80 +0,0 @@
# CurrenciCombo orchestrator production env (Phoenix CT 8604 / any systemd host)
#
# Installed by scripts/deployment/install.sh to:
# /etc/currencicombo/orchestrator.env
#
# Loaded by the currencicombo-orchestrator.service systemd unit via
# EnvironmentFile=. Values that are committed here are safe defaults;
# secrets are left blank and must be set before first boot.
#
# The portal is a statically built SPA (nginx), so it takes NO runtime env.
# Any VITE_* vars needed at build time are baked into dist/ by
# scripts/deployment/deploy-currencicombo-8604.sh before the rsync.
############################################################
# Server
############################################################
NODE_ENV=production
PORT=8080
# Bind to loopback only when behind NPMplus on the same host; bind
# 0.0.0.0 if NPMplus is on a different host (the CT 8604 case, so 0.0.0.0).
HOST=0.0.0.0
############################################################
# Postgres (local to the CT per install.sh)
############################################################
DATABASE_URL=postgresql://currencicombo:replace-me-on-install@127.0.0.1:5432/currencicombo
############################################################
# Redis (local to the CT per install.sh)
############################################################
REDIS_URL=redis://127.0.0.1:6379
############################################################
# Event bus signing (REQUIRED). install.sh generates this on first run
# via `openssl rand -hex 32` unless the file already exists.
############################################################
EVENT_SIGNING_SECRET=
############################################################
# API keys per role (REQUIRED). install.sh generates three random
# initiator/settler/auditor keys on first run unless set.
# Format: key1:role1,key2:role2,...
############################################################
API_KEYS=
############################################################
# Chain 138 — resolves EXT-CHAIN138-CI-RPC (already resolved).
############################################################
CHAIN_138_RPC_URL=https://rpc.public-0138.defi-oracle.io
CHAIN_138_CHAIN_ID=138
# Leave empty to run mock notary. Populate after running
# `contracts/scripts/deploy-notary-registry.ts` once.
NOTARY_REGISTRY_ADDRESS=
# Leave empty to run mock notary. Otherwise 0x-prefixed 32-byte hex.
ORCHESTRATOR_PRIVATE_KEY=
############################################################
# External dependency blockers (leave blank → mock fallback + EXT-* log)
# These are the exact IDs that the Proxmox
# scripts/verify/check-external-dependencies.sh gate knows about.
############################################################
# EXT-DBIS-CORE — set when dbis_core is deployed and reachable.
DBIS_CORE_URL=
# EXT-FIN-GATEWAY — set when a real Alliance Access / FIN gateway is
# provisioned. Leave blank to use PR R's in-process sandbox.
FIN_SANDBOX_URL=
# EXT-CC-* — the following four blockers are upstream-scaffold repos
# (cc-payment-adapters, cc-audit-ledger, cc-shared-events,
# cc-shared-schemas). They cannot be resolved from this repo; no
# env var flips them. The orchestrator logs EXT-CC-* as active on boot.
# Identity + controls matrix (not a blocker IDs per se — they ship
# today via the cc-identity-core and cc-compliance-controls adapters
# merged in PR V/W). Blank keeps the embedded v0 matrix + mock identity.
CC_IDENTITY_URL=
CC_CONTROLS_MATRIX_URL=

View File

@@ -1,254 +0,0 @@
# CurrenciCombo — Phoenix / systemd deployment
This directory holds everything needed to deploy CurrenciCombo onto a
systemd host — starting with Phoenix CT 8604 on `r630-01`, but any
Debian/Ubuntu (or Alpine) host with Postgres + Redis available works.
The files here are **target-agnostic**. They hardcode no IPs, hostnames,
or VLANs. Environment-specific values — `curucombo.曼李.com`, the
`10.160.0.14` VIP, the NPMplus reverse proxy — are applied at the
edge (NPMplus) and at `/etc/currencicombo/orchestrator.env`, never in
the repo.
## Architecture on CT 8604
```
┌────────────────────┐
curucombo.曼李.com ──▶ NPMplus │192.168.11.167 │
(Cloudflare-proxied) │ TLS terminates here│
└─────────┬──────────┘
┌──────────────────────┴──────────────────────┐
│ │
▼ ▼
curucombo.曼李.com/* (default) curucombo.曼李.com/api/*
(incl. SSE /api/plans/*/events/stream)
│ │
CT 8604 │10.160.0.14:3000 CT 8604 │10.160.0.14:8080
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ currencicombo-webapp.service │ │ currencicombo-orchestrator │
│ nginx → /opt/currencicombo/ │ │ .service (systemd) │
│ webapp/dist/ │ │ node dist/index.js │
└─────────────────────────────┘ │ env /etc/currencicombo/ │
│ orchestrator.env │
└──────────────┬──────────────┘
postgresql + redis (same CT, local)
```
## Files
| path | purpose |
|---|---|
| `systemd/currencicombo-orchestrator.service` | Node orchestrator, reads `/etc/currencicombo/orchestrator.env` |
| `systemd/currencicombo-webapp.service` | nginx serving the Vite SPA on `:3000` |
| `webapp-nginx.conf` | full nginx.conf for the webapp unit |
| `.env.prod.example` | env template installed to `/etc/currencicombo/orchestrator.env` |
| `install.sh` | one-shot host setup: user / dirs / DB role / systemd units / first-run key handoff file |
| `install-prune-cron.sh` | opt-in daily cron that prunes `/var/lib/currencicombo/backups/` (30-day retention, keep-min 5) |
| `deploy-currencicombo-8604.sh` | build-and-swap deploy driver (the script Phoenix/proxmox deploy-api calls) |
| `README.md` | you're reading it |
## First-time setup on CT 8604
All commands run as **root** inside the CT.
1. Ensure Postgres + Redis are installed and running:
```
apt-get install -y postgresql redis-server
systemctl enable --now postgresql redis-server
```
2. Clone the repo into its staging location (once):
```
install -d -o root -g root /var/lib/currencicombo
git clone https://gitea.d-bis.org/d-bis/CurrenciCombo.git /var/lib/currencicombo/repo
```
3. Run `install.sh` (creates user, DB, systemd units, env file):
```
bash /var/lib/currencicombo/repo/scripts/deployment/install.sh
```
On success you'll see:
```
[install] generated EVENT_SIGNING_SECRET (64 hex)
[install] generated 3 API keys (initiator/settler/auditor)
[install] initial secrets written to /root/currencicombo-first-keys.txt (0600) — record in password manager, then 'shred -u /root/currencicombo-first-keys.txt'
[install] install complete.
```
`install.sh` writes the three API keys + `EVENT_SIGNING_SECRET` to **two** places:
- `/etc/currencicombo/orchestrator.env` — canonical, read by systemd (`0640`, owned by `currencicombo`).
- `/root/currencicombo-first-keys.txt` — **root-only handoff file** (`0600`). Grab it once, record the values in your password manager, then `shred -u` it.
The handoff file is **not** regenerated on re-run — if `orchestrator.env` already exists, `install.sh` does not produce new secrets.
4. (Optional) Install the backup-pruning cron:
```
bash /var/lib/currencicombo/repo/scripts/deployment/install-prune-cron.sh
```
Drops a `/etc/cron.daily/currencicombo-prune-backups` that deletes anything under `/var/lib/currencicombo/backups/` older than 30 days while **always keeping the newest 5** regardless of age. Safe on re-run; opt out with `sudo rm /etc/cron.daily/currencicombo-prune-backups`.
5. If you need to resolve any `EXT-*` blocker (e.g. point at a real dbis_core), edit `/etc/currencicombo/orchestrator.env` before the first deploy.
6. First build-and-start:
```
bash /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh
```
Expected tail:
```
[deploy] orchestrator ready: {"ready":true}
[deploy] portal OK (HTTP 200)
[deploy] EXT-* blocker summary from orchestrator boot log:
[ExternalBlockers] 6 active, 1 resolved
id: EXT-DBIS-CORE
id: EXT-CC-PAYMENT-ADAPTERS
...
id: EXT-CHAIN138-CI-RPC (resolved)
[deploy] deploy complete. ref=main sha=<short> ts=<timestamp>
```
## NPMplus ingress changes required at cutover
`curucombo.曼李.com` today proxies 100% to `10.160.0.14:3000`. After
cutover it must become a **single-origin path-routed proxy** with **two**
rules (the SSE endpoint lives at `/api/plans/:id/events/stream`, so it's
already under `/api/*` — no separate `/events/*` rule is needed):
| location | upstream | proxy settings |
|---|---|---|
| `/api/*` | `http://10.160.0.14:8080` | **SSE-friendly settings apply here because the SSE route `/api/plans/:id/events/stream` is under /api/**. Use `proxy_pass http://10.160.0.14:8080;` with **no trailing slash** so `/api/...` reaches the orchestrator unchanged. Set: `proxy_http_version 1.1;`, `proxy_set_header Connection "";`, `proxy_buffering off;`, `proxy_cache off;`, `proxy_read_timeout 24h;`, `proxy_send_timeout 24h;`. Standard forwarding: `proxy_set_header Host $host;`, `X-Real-IP $remote_addr;`, `X-Forwarded-For $proxy_add_x_forwarded_for;`, `X-Forwarded-Proto $scheme;`. The slight overhead of `proxy_buffering off` on plain REST calls is negligible for this workload. |
| `/` | `http://10.160.0.14:3000` | Vite SPA. Default upstream. No special settings. |
If you skip the `/api/*` rule, the nginx in `webapp-nginx.conf`
intentionally returns `HTTP 421` for that path — a clean "upstream is
misconfigured" signal instead of silently returning `index.html` and
breaking the browser with a JSON parse error.
## Subsequent deploys
Every deploy after the first is just:
```
sudo /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh
```
Flags:
- `--ref=<branch-or-sha>` — deploy something other than `main`.
- `--dry-run` — print what would happen, don't touch anything.
- `--skip-migrate` — hotfix deploys that don't change the schema.
- `--skip-build` — reuse the build from the previous run (debugging only).
- `--rollback` — restore the most recent `/var/lib/currencicombo/backups/<ts>/` and restart units. Does **not** git-pull or rebuild.
Every deploy writes a timestamped backup to
`/var/lib/currencicombo/backups/<YYYYmmdd-HHMMSS>/` before swapping. Pruning is opt-in via `install-prune-cron.sh` (30-day retention, keep-min 5). Without the cron, backups accumulate forever — quietly filling `/var/lib` is how the next outage starts.
## Failure handling on deploy
**Rollback is manual.** `deploy-currencicombo-8604.sh` **does not** auto-restore the previous backup if the orchestrator fails to become ready. First cutovers typically fail because of env typos or migration mistakes, and auto-restoring hides the failure state ops needs.
Instead, on a readiness timeout the deploy script prints:
- last 40 lines of `journalctl -u currencicombo-orchestrator`
- last 20 lines of `journalctl -u currencicombo-webapp`
- **the exact `--rollback` command with the specific backup path filled in**
Example tail on failure:
```
================================================================
DEPLOY FAILED: orchestrator did not become ready after 60s
================================================================
## currencicombo-orchestrator (last 40 lines):
... env validation error: EVENT_SIGNING_SECRET is required ...
## Units are in whatever state deploy left them. To restore
## the previous build (does NOT revert DB migrations):
sudo /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh --rollback
# (will restore /var/lib/currencicombo/backups/20260423-140215)
================================================================
```
Rollback one-liner (when ops has decided to restore):
```
sudo /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh --rollback
```
Rollback restores the most recent backup and restarts both units. It **does not** touch the DB. If the failed deploy applied a new migration, DB rollback is a manual `psql` task — the orchestrator's migration runner only emits `up()` paths.
## Post-cutover smoke checks through NPMplus
Once the NPMplus `/api/*` rule is live, from a workstation (not the CT):
```
# 1. Front-door TLS is healthy
curl -skI https://curucombo.xn--vov0g.com/ | head -3
# expect: HTTP/2 200
# expect: NO 'x-nextjs-prerender' header (that was the old Next.js build)
# 2. SPA is the new Vite portal
curl -sk https://curucombo.xn--vov0g.com/ | grep -oE '<title>[^<]+</title>'
# expect: <title>Solace Bank Group PLC — Treasury Management Portal</title>
# 3. Orchestrator ready through NPMplus
curl -sk https://curucombo.xn--vov0g.com/api/ready | head -1
# expect: {"ready":true} (not HTML)
# 4. Orchestrator blocker log (through CT shell, not NPMplus)
ssh root@10.160.0.14 'journalctl -u currencicombo-orchestrator -n 200 | grep -E "ExternalBlockers|EXT-"'
# expect: [ExternalBlockers] 6 active, 1 resolved
# expect: one line per EXT-* id
# 5. SSE actually streams (catches silent NPMplus proxy_buffering=on misconfig)
curl -sk -N --max-time 5 -H 'Accept: text/event-stream' \
https://curucombo.xn--vov0g.com/api/plans/demo-pay-014/events/stream \
| head -20 || true
# expect: HTTP/2 200 with Content-Type: text/event-stream
# expect: at least one 'data: {...}\n\n' frame to arrive WITHIN ~1s
# if you see nothing for 3-5s and then everything dumps at once:
# NPMplus has proxy_buffering=on. Fix: proxy_buffering off; proxy_http_version 1.1; proxy_set_header Connection "";
# if the ping is 401/403: expected — SSE is auth-gated; the point is to
# prove the request REACHED the orchestrator (content-type header +
# chunked response headers) rather than hitting the Vite SPA.
```
A plain `HTTP/2 200` with a `Content-Type: text/html` body on `/api/ready` means NPMplus is silently falling back to the `/` rule — the `/api/*` rule is missing or ordered wrong. The `webapp-nginx.conf` in this repo returns `HTTP 421` for `/api/*` to make that case obvious when debugging CT-locally, but at the NPMplus edge nginx serves whatever NPMplus routes to it.
## Troubleshooting
| symptom | cause / check |
|---|---|
| `/api/*` returns `421 NPMplus is misconfigured` | NPMplus `/api/*` rule missing or wrong upstream. |
| `/events/*` connects then disconnects after ~60s | NPMplus forgot `proxy_buffering off` + high `proxy_read_timeout`. |
| orchestrator unit enters `activating (auto-restart)` loop | `journalctl -u currencicombo-orchestrator -n 80` — usually a zod env-validation error. The boot-time assertion message names the missing/invalid var. |
| orchestrator boot log says `[ExternalBlockers] N active` where N > 6 | you added an `EXT-*` env var without also updating the central registry in `orchestrator/src/config/externalBlockers.ts`. |
| `/health` returns 503 but `/ready` is 200 | memory `critical` is a separate signal from readiness. Inspect CT memory; this happens on constrained builders and is not a deploy bug. |
| portal page loads but MetaMask login does nothing | the portal couldn't reach `/api/auth/*`. Walk back up the NPMplus rule chain. |
## Cutting over from the pre-existing Next.js build
Phoenix previously had an older Next.js "ISO-20022 Combo Flow" app in
`/opt/currencicombo/webapp`. The cutover sequence on CT 8604 is:
1. **Backup the old install** out-of-band:
```
tar czf /root/currencicombo-preRepo-$(date +%s).tgz /opt/currencicombo /etc/currencicombo 2>/dev/null || true
```
2. **Disable the pre-existing systemd units** (they're the same names but point at the old tree):
```
systemctl stop currencicombo-webapp currencicombo-orchestrator
systemctl disable currencicombo-webapp currencicombo-orchestrator
```
3. Run `install.sh` (writes the new units, new nginx, new env). On an already-set-up host this is idempotent: it preserves `/etc/currencicombo/orchestrator.env` if it already exists.
4. Run `deploy-currencicombo-8604.sh`.
5. Apply the NPMplus `/api` + `/` path rules.
6. Smoke from outside the CT: `curl -skI https://curucombo.xn--vov0g.com/ && curl -sk https://curucombo.xn--vov0g.com/api/ready`.
## Proxmox-side follow-up (not in this PR)
After this PR merges and the above cutover runs cleanly, the
`/home/intlc/projects/proxmox` repo needs a separate commit to:
- Update `phoenix-deploy-api/deploy-targets.json` to point at:
- repo: `d-bis/CurrenciCombo`
- branch: `main`
- target: `default`
- deploy entrypoint: `scripts/deployment/deploy-currencicombo-8604.sh`
- Remove any stale `/opt/currencicombo/webapp` Next.js references.
- Drop any description of `ignoreBuildErrors: true` in `webapp/next.config.ts` — the new webapp is Vite+tsc-strict, no build-error suppression.

View File

@@ -1,236 +0,0 @@
#!/usr/bin/env bash
# deploy-currencicombo-8604.sh — build-and-swap deploy for CurrenciCombo.
#
# Runs on a systemd host that has already had `install.sh` applied once.
# This is the script referenced by the Proxmox repo's
# `phoenix-deploy-api/deploy-targets.json` tuple
# (repo=d-bis/CurrenciCombo, branch=main, target=default).
#
# Steps (each idempotent, each can be --dry-run'd):
# 1. git clone/pull /var/lib/currencicombo/repo to the target ref.
# 2. Build orchestrator (npm ci + npm run build).
# 3. Build portal/webapp (npm ci + npm run build), baking
# VITE_ORCHESTRATOR_URL into the bundle.
# 4. Run DB migrations (npm run migrate in orchestrator/).
# 5. Stop systemd units.
# 6. rsync build output into /opt/currencicombo/{orchestrator,webapp}.
# 7. Start systemd units.
# 8. Smoke-test /ready + portal / + print EXT-* blocker summary.
#
# Rollback: `--rollback` restores the previous backup under
# /var/lib/currencicombo/backups/<timestamp>.
#
# CT 8604 is in the filename for ops-grep-ability; the script itself is
# host-agnostic. Override paths via env vars if you run it elsewhere.
set -euo pipefail
# ----- defaults (override via env) ------------------------------------
: "${CC_GIT_REMOTE:=https://gitea.d-bis.org/d-bis/CurrenciCombo.git}"
: "${CC_GIT_REF:=main}"
: "${CC_REPO_DIR:=/var/lib/currencicombo/repo}"
: "${CC_APP_HOME:=/opt/currencicombo}"
: "${CC_BACKUP_DIR:=/var/lib/currencicombo/backups}"
: "${CC_USER:=currencicombo}"
# Portal build-time env. The NPMplus ingress path-routes /api/* and
# /events/* to the orchestrator, so same-origin works.
: "${VITE_ORCHESTRATOR_URL:=https://curucombo.xn--vov0g.com}"
: "${ORCHESTRATOR_UNIT:=currencicombo-orchestrator.service}"
: "${WEBAPP_UNIT:=currencicombo-webapp.service}"
: "${CC_HEALTH_URL:=http://127.0.0.1:8080/ready}"
: "${CC_PORTAL_URL:=http://127.0.0.1:3000/}"
: "${CC_HEALTH_TIMEOUT_SECS:=60}"
# ----- flags ----------------------------------------------------------
DRY_RUN=0
SKIP_MIGRATE=0
SKIP_BUILD=0
DO_ROLLBACK=0
usage() {
cat <<'USAGE'
Usage: sudo ./deploy-currencicombo-8604.sh [flags]
Flags:
--ref=<git-ref> Override CC_GIT_REF (default: main)
--dry-run Print commands, don't run them
--skip-migrate Skip `npm run migrate` step (use for hotfix
deploys where schema hasn't changed)
--skip-build Reuse the existing build in CC_REPO_DIR/dist
(useful after `--dry-run --skip-build=no` from
the previous run)
--rollback Restore the most recent backup and restart.
Does not run git/build/migrate.
-h, --help This help
Env overrides:
CC_GIT_REMOTE, CC_GIT_REF, CC_REPO_DIR, CC_APP_HOME, CC_BACKUP_DIR,
CC_USER, VITE_ORCHESTRATOR_URL, ORCHESTRATOR_UNIT, WEBAPP_UNIT,
CC_HEALTH_URL, CC_PORTAL_URL, CC_HEALTH_TIMEOUT_SECS
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--ref=*) CC_GIT_REF="${1#*=}"; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--skip-migrate) SKIP_MIGRATE=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
--rollback) DO_ROLLBACK=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
log() { printf '[deploy] %s\n' "$*" >&2; }
warn() { printf '[deploy][WARN] %s\n' "$*" >&2; }
die() { printf '[deploy][FATAL] %s\n' "$*" >&2; exit 1; }
run() { if [[ "${DRY_RUN}" -eq 1 ]]; then printf '[deploy][dry-run] %s\n' "$*" >&2; else eval "$*"; fi; }
runcc() { if [[ "${DRY_RUN}" -eq 1 ]]; then printf '[deploy][dry-run][as %s] %s\n' "${CC_USER}" "$*" >&2; else sudo -u "${CC_USER}" -H bash -lc "$*"; fi; }
[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)"
# ----- rollback fast-path ---------------------------------------------
if [[ "${DO_ROLLBACK}" -eq 1 ]]; then
LATEST="$(ls -1dt "${CC_BACKUP_DIR}"/* 2>/dev/null | head -1 || true)"
[[ -n "${LATEST}" ]] || die "no backup under ${CC_BACKUP_DIR}"
log "rolling back to ${LATEST}"
run "systemctl stop '${WEBAPP_UNIT}' '${ORCHESTRATOR_UNIT}'"
run "rsync -a --delete '${LATEST}/orchestrator/' '${CC_APP_HOME}/orchestrator/'"
run "rsync -a --delete '${LATEST}/webapp/' '${CC_APP_HOME}/webapp/'"
run "systemctl start '${ORCHESTRATOR_UNIT}' '${WEBAPP_UNIT}'"
log "rollback applied. systemctl status ${ORCHESTRATOR_UNIT} to verify."
exit 0
fi
# ----- 1. git ---------------------------------------------------------
run "install -d -o '${CC_USER}' -g '${CC_USER}' -m 0755 '${CC_REPO_DIR}'"
run "chown -R '${CC_USER}:${CC_USER}' '${CC_REPO_DIR}'"
if [[ ! -d "${CC_REPO_DIR}/.git" && "${CC_GIT_REF}" != "local" ]]; then
log "cloning ${CC_GIT_REMOTE}${CC_REPO_DIR}"
runcc "git clone '${CC_GIT_REMOTE}' '${CC_REPO_DIR}'"
fi
if [[ -d "${CC_REPO_DIR}/.git" && "${CC_GIT_REF}" != "local" ]]; then
runcc "cd '${CC_REPO_DIR}' && git fetch --prune origin"
runcc "cd '${CC_REPO_DIR}' && git reset --hard 'origin/${CC_GIT_REF}'"
REF_SHA="$(sudo -u "${CC_USER}" git -C "${CC_REPO_DIR}" rev-parse --short HEAD 2>/dev/null || echo unknown)"
log "repo at ${CC_GIT_REF} = ${REF_SHA}"
else
REF_SHA="local"
log "using staged local workspace from ${CC_REPO_DIR}"
fi
# ----- 2. orchestrator build -----------------------------------------
if [[ "${SKIP_BUILD}" -eq 0 ]]; then
log "building orchestrator"
if [[ -f "${CC_REPO_DIR}/orchestrator/package-lock.json" ]]; then
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm ci --no-audit --no-fund"
else
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm install --no-audit --no-fund"
fi
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm run build"
log "building portal (VITE_ORCHESTRATOR_URL=${VITE_ORCHESTRATOR_URL})"
runcc "cd '${CC_REPO_DIR}' && npm ci --include=optional --no-audit --no-fund || npm ci --include=optional --force --no-audit --no-fund"
runcc "cd '${CC_REPO_DIR}' && VITE_ORCHESTRATOR_URL='${VITE_ORCHESTRATOR_URL}' npm run build"
else
log "skipping builds (--skip-build)"
fi
# ----- 3. migrations --------------------------------------------------
if [[ "${SKIP_MIGRATE}" -eq 0 ]]; then
log "running DB migrations"
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm run migrate"
else
log "skipping migrations (--skip-migrate)"
fi
# ----- 4. backup previous install ------------------------------------
TS="$(date +%Y%m%d-%H%M%S)"
BACKUP="${CC_BACKUP_DIR}/${TS}"
if [[ -d "${CC_APP_HOME}/orchestrator/dist" || -d "${CC_APP_HOME}/webapp/dist" ]]; then
log "backing up current install → ${BACKUP}"
run "install -d -o root -g root -m 0700 '${BACKUP}/orchestrator' '${BACKUP}/webapp'"
run "rsync -a '${CC_APP_HOME}/orchestrator/' '${BACKUP}/orchestrator/'"
run "rsync -a '${CC_APP_HOME}/webapp/' '${BACKUP}/webapp/'"
fi
# ----- 5. stop units --------------------------------------------------
log "stopping systemd units"
run "systemctl stop '${WEBAPP_UNIT}' || true"
run "systemctl stop '${ORCHESTRATOR_UNIT}' || true"
# ----- 6. swap in new build ------------------------------------------
log "rsyncing new build into ${CC_APP_HOME}"
# Orchestrator: dist/ + node_modules/ + package.json + package-lock.json
runcc "rsync -a --delete '${CC_REPO_DIR}/orchestrator/dist/' '${CC_APP_HOME}/orchestrator/dist/'"
runcc "rsync -a '${CC_REPO_DIR}/orchestrator/node_modules/' '${CC_APP_HOME}/orchestrator/node_modules/'"
runcc "cp '${CC_REPO_DIR}/orchestrator/package.json' '${CC_APP_HOME}/orchestrator/package.json'"
runcc "if [[ -f '${CC_REPO_DIR}/orchestrator/package-lock.json' ]]; then cp '${CC_REPO_DIR}/orchestrator/package-lock.json' '${CC_APP_HOME}/orchestrator/package-lock.json'; else rm -f '${CC_APP_HOME}/orchestrator/package-lock.json'; fi"
# Webapp: dist/
runcc "rsync -a --delete '${CC_REPO_DIR}/dist/' '${CC_APP_HOME}/webapp/dist/'"
# ----- 7. start units ------------------------------------------------
log "starting systemd units"
run "systemctl start '${ORCHESTRATOR_UNIT}'"
run "systemctl start '${WEBAPP_UNIT}'"
# ----- 8. smoke -------------------------------------------------------
if [[ "${DRY_RUN}" -eq 1 ]]; then
log "dry-run: skipping smoke test"
exit 0
fi
log "waiting up to ${CC_HEALTH_TIMEOUT_SECS}s for orchestrator ${CC_HEALTH_URL}"
SECS=0
until curl -sfL --max-time 3 "${CC_HEALTH_URL}" >/dev/null 2>&1; do
SECS=$((SECS + 2))
if [[ "${SECS}" -ge "${CC_HEALTH_TIMEOUT_SECS}" ]]; then
# Loud failure summary. Deliberately does NOT auto-rollback — first
# cutovers often fail because of env/migration mistakes, and
# auto-restoring the old build hides the failure state ops needs to
# diagnose. Print the exact --rollback command with the specific
# backup path filled in, so it's one copy-paste away if desired.
{
echo
echo "================================================================"
echo "DEPLOY FAILED: orchestrator did not become ready after ${CC_HEALTH_TIMEOUT_SECS}s"
echo "================================================================"
echo
echo "## currencicombo-orchestrator (last 40 lines):"
journalctl -u "${ORCHESTRATOR_UNIT}" -n 40 --no-pager 2>&1 || echo "(journalctl unavailable)"
echo
echo "## currencicombo-webapp (last 20 lines):"
journalctl -u "${WEBAPP_UNIT}" -n 20 --no-pager 2>&1 || echo "(journalctl unavailable)"
echo
echo "## Units are in whatever state deploy left them. To restore"
echo "## the previous build (does NOT revert DB migrations):"
echo
if [[ -n "${BACKUP:-}" && -d "${BACKUP}" ]]; then
echo " sudo $0 --rollback"
echo " # (will restore ${BACKUP})"
else
echo " # No backup was taken (first deploy). Manual recovery required."
fi
echo
echo "================================================================"
} >&2
exit 1
fi
sleep 2
done
log "orchestrator ready: $(curl -sf "${CC_HEALTH_URL}")"
log "probing portal ${CC_PORTAL_URL}"
PORTAL_CODE="$(curl -s -o /dev/null -w '%{http_code}' "${CC_PORTAL_URL}" || echo ERR)"
[[ "${PORTAL_CODE}" =~ ^2 ]] || die "portal returned HTTP ${PORTAL_CODE}"
log "portal OK (HTTP ${PORTAL_CODE})"
log "EXT-* blocker summary from orchestrator boot log:"
journalctl -u "${ORCHESTRATOR_UNIT}" --no-pager -n 200 \
| grep -E 'ExternalBlockers|EXT-[A-Z0-9-]+' | tail -20 || true
log "deploy complete. ref=${CC_GIT_REF} sha=${REF_SHA} ts=${TS}"

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env bash
# install-prune-cron.sh — opt-in cron job to prune old deploy backups.
#
# Run ONCE as root (or with sudo) after install.sh to enable daily
# pruning of /var/lib/currencicombo/backups/. The pruner:
# - deletes entries older than 30 days
# - ALWAYS keeps the newest N backups regardless of age (default 5)
#
# No-op on re-run. Opt out by removing /etc/cron.daily/currencicombo-prune-backups.
set -euo pipefail
BACKUP_DIR="${CC_BACKUP_DIR:-/var/lib/currencicombo/backups}"
RETAIN_DAYS="${CC_BACKUP_RETAIN_DAYS:-30}"
KEEP_MIN="${CC_BACKUP_KEEP_MIN:-5}"
CRON_FILE="/etc/cron.daily/currencicombo-prune-backups"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
-h|--help)
cat <<'USAGE'
Usage: sudo ./install-prune-cron.sh [--dry-run]
Env overrides:
CC_BACKUP_DIR (default: /var/lib/currencicombo/backups)
CC_BACKUP_RETAIN_DAYS (default: 30)
CC_BACKUP_KEEP_MIN (default: 5)
USAGE
exit 0 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
log() { printf '[install-prune-cron] %s\n' "$*" >&2; }
die() { printf '[install-prune-cron][FATAL] %s\n' "$*" >&2; exit 1; }
[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)"
# The pruner script body. Runs daily via cron.daily.
# KEEP_MIN is enforced by listing backups newest-first, skipping the
# first KEEP_MIN, then deleting any remaining entries older than
# RETAIN_DAYS. This means we always keep at least KEEP_MIN (even if
# they're all <30 days old), and never delete one of the newest
# KEEP_MIN (even if it's >30 days old on a dormant host).
read -r -d '' PRUNER_BODY <<PRUNER || true
#!/usr/bin/env bash
# Managed by scripts/deployment/install-prune-cron.sh. Edits overwritten
# on next install. Opt out by deleting this file.
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR}"
RETAIN_DAYS=${RETAIN_DAYS}
KEEP_MIN=${KEEP_MIN}
[[ -d "\$BACKUP_DIR" ]] || exit 0
cd "\$BACKUP_DIR"
mapfile -t all < <(find . -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' 2>/dev/null | sort -rn | awk '{print \$2}')
count=\${#all[@]}
if (( count <= KEEP_MIN )); then
logger -t currencicombo-prune "count=\$count <= KEEP_MIN=\$KEEP_MIN; nothing to prune"
exit 0
fi
cutoff=\$(date -d "\$RETAIN_DAYS days ago" +%s)
deleted=0
kept=0
for i in "\${!all[@]}"; do
p="\${all[\$i]}"
if (( i < KEEP_MIN )); then
kept=\$((kept + 1))
continue
fi
mtime=\$(stat -c %Y "\$p" 2>/dev/null || echo 0)
if (( mtime < cutoff )); then
rm -rf -- "\$p"
deleted=\$((deleted + 1))
else
kept=\$((kept + 1))
fi
done
logger -t currencicombo-prune "deleted=\$deleted kept=\$kept total_before=\$count"
PRUNER
if [[ "${DRY_RUN}" -eq 1 ]]; then
log "[dry-run] would write ${CRON_FILE} (0755) with pruner targeting ${BACKUP_DIR}, retain ${RETAIN_DAYS}d, keep-min ${KEEP_MIN}"
echo "---"
echo "${PRUNER_BODY}"
echo "---"
exit 0
fi
printf '%s\n' "${PRUNER_BODY}" > "${CRON_FILE}"
chmod 0755 "${CRON_FILE}"
chown root:root "${CRON_FILE}"
log "installed ${CRON_FILE} (backups older than ${RETAIN_DAYS}d, keep-min ${KEEP_MIN}, target ${BACKUP_DIR})"
log "runs daily via /etc/cron.daily/. Opt out: sudo rm ${CRON_FILE}"
log "logs to syslog (tag currencicombo-prune); journalctl -t currencicombo-prune"

View File

@@ -1,252 +0,0 @@
#!/usr/bin/env bash
# install.sh — idempotent first-time setup for CurrenciCombo on a systemd host.
#
# Intended to run ONCE per host as root (or with sudo). Running it again is
# safe: it will skip already-present artifacts and warn on conflicts.
#
# What this does:
# 1. Creates the `currencicombo` system user and /opt/currencicombo tree.
# 2. Installs nginx (Debian/Ubuntu or Alpine) if not present.
# 3. Ensures a local Postgres is running and creates a fresh
# `currencicombo` role + DB (refuses to touch an existing one unless
# --force-recreate is passed).
# 4. Ensures a local Redis is running.
# 5. Writes /etc/currencicombo/orchestrator.env from .env.prod.example,
# auto-populating EVENT_SIGNING_SECRET and ORCHESTRATOR_API_KEYS with
# fresh randoms the first time.
# 6. Installs /etc/currencicombo/webapp-nginx.conf.
# 7. Installs the two systemd units and runs `systemctl daemon-reload`.
# 8. Enables (does NOT start) both units. First start happens via
# scripts/deployment/deploy-currencicombo-8604.sh after the first
# successful build.
#
# This script is target-agnostic. It has no hardcoded IP / hostname /
# VLAN. The NPMplus ingress in front of it is configured separately —
# see scripts/deployment/README.md.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
APP_USER="currencicombo"
APP_HOME="/opt/currencicombo"
ETC_DIR="/etc/currencicombo"
LOG_DIR="/var/log/currencicombo"
REPO_DIR="/var/lib/currencicombo/repo"
ENV_FILE="${ETC_DIR}/orchestrator.env"
NGINX_FILE="${ETC_DIR}/webapp-nginx.conf"
SYSTEMD_DIR="/etc/systemd/system"
FORCE_RECREATE_DB=0
DRY_RUN=0
SKIP_NGINX_INSTALL=0
log() { printf '[install] %s\n' "$*" >&2; }
warn() { printf '[install][WARN] %s\n' "$*" >&2; }
die() { printf '[install][FATAL] %s\n' "$*" >&2; exit 1; }
run() { if [[ "${DRY_RUN}" -eq 1 ]]; then printf '[install][dry-run] %s\n' "$*" >&2; else eval "$*"; fi; }
sql_escape() {
printf "%s" "$1" | sed "s/'/''/g"
}
usage() {
cat <<'USAGE'
Usage: sudo ./install.sh [--force-recreate-db] [--skip-nginx-install] [--dry-run]
--force-recreate-db DROP and recreate the currencicombo Postgres role
and DB even if they already exist. DESTRUCTIVE.
--skip-nginx-install Do not apt/apk install nginx (use if you already
have a custom nginx build in place).
--dry-run Print the commands that would run, don't run them.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--force-recreate-db) FORCE_RECREATE_DB=1; shift ;;
--skip-nginx-install) SKIP_NGINX_INSTALL=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown arg: $1" ;;
esac
done
[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)"
# ----------------------------------------------------------------------
# 1. User + tree
# ----------------------------------------------------------------------
if id "${APP_USER}" >/dev/null 2>&1; then
log "user ${APP_USER} already exists"
else
log "creating system user ${APP_USER}"
run useradd --system --home-dir "${APP_HOME}" --shell /usr/sbin/nologin --user-group "${APP_USER}"
fi
for d in "${APP_HOME}" "${APP_HOME}/orchestrator" "${APP_HOME}/webapp" \
"${APP_HOME}/webapp/dist" "${ETC_DIR}" "${LOG_DIR}" "${REPO_DIR}"; do
run install -d -o "${APP_USER}" -g "${APP_USER}" -m 0755 "$d"
done
run chown "${APP_USER}:${APP_USER}" "${APP_HOME}" "${LOG_DIR}" "${REPO_DIR}"
run chmod 0750 "${ETC_DIR}"
# ----------------------------------------------------------------------
# 2. nginx (required by currencicombo-webapp.service)
# ----------------------------------------------------------------------
if [[ "${SKIP_NGINX_INSTALL}" -eq 0 ]]; then
if command -v nginx >/dev/null 2>&1; then
log "nginx already installed ($(nginx -v 2>&1 | head -1))"
elif command -v apt-get >/dev/null 2>&1; then
log "installing nginx via apt"
run 'DEBIAN_FRONTEND=noninteractive apt-get update -q'
run 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends nginx-light'
# We use our own nginx.conf via -c, so disable the distro site.
run systemctl disable --now nginx 2>/dev/null || true
elif command -v apk >/dev/null 2>&1; then
log "installing nginx via apk"
run apk add --no-cache nginx
run rc-update del nginx 2>/dev/null || true
else
die "no apt or apk available — install nginx manually or re-run with --skip-nginx-install"
fi
fi
[[ -f /etc/nginx/mime.types ]] || warn "/etc/nginx/mime.types missing; webapp-nginx.conf may fail"
# ----------------------------------------------------------------------
# 3. Postgres role + DB
# ----------------------------------------------------------------------
if ! command -v psql >/dev/null 2>&1; then
die "psql not on PATH — install Postgres on this host (e.g. apt install postgresql) before running install.sh"
fi
# Use the OS `postgres` superuser for DDL.
pg_role_exists() {
sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${APP_USER}';" 2>/dev/null | grep -q 1
}
pg_db_exists() {
sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${APP_USER}';" 2>/dev/null | grep -q 1
}
if pg_role_exists; then
if [[ "${FORCE_RECREATE_DB}" -eq 1 ]]; then
log "dropping existing role/DB (--force-recreate-db)"
run "sudo -u postgres psql -c 'DROP DATABASE IF EXISTS ${APP_USER};'"
run "sudo -u postgres psql -c 'DROP ROLE IF EXISTS ${APP_USER};'"
else
warn "Postgres role ${APP_USER} already exists — skipping role/DB creation. Re-run with --force-recreate-db to wipe."
fi
fi
if ! pg_role_exists; then
log "creating Postgres role ${APP_USER}"
run "sudo -u postgres psql -c \"CREATE ROLE ${APP_USER} LOGIN;\""
fi
if ! pg_db_exists; then
log "creating Postgres database ${APP_USER}"
run "sudo -u postgres psql -c \"CREATE DATABASE ${APP_USER} OWNER ${APP_USER};\""
fi
# ----------------------------------------------------------------------
# 4. Redis
# ----------------------------------------------------------------------
if systemctl list-unit-files | grep -q '^redis-server\.service'; then
run "systemctl start redis-server.service || true"
run "systemctl enable redis-server.service >/dev/null 2>&1 || true"
elif systemctl list-unit-files | grep -q '^redis\.service'; then
run "systemctl start redis.service || true"
run "systemctl enable redis.service >/dev/null 2>&1 || true"
elif command -v redis-cli >/dev/null 2>&1; then
warn "redis-cli present but no redis-server.service / redis.service unit — assuming external Redis"
else
warn "redis not detected; orchestrator will fall back to in-process event bus. Install redis for multi-replica support."
fi
# ----------------------------------------------------------------------
# 5. orchestrator.env
# ----------------------------------------------------------------------
FIRST_KEYS_FILE="/root/currencicombo-first-keys.txt"
if [[ -f "${ENV_FILE}" ]]; then
log "${ENV_FILE} already exists — leaving alone (no new keys generated)"
else
log "writing ${ENV_FILE}"
install -o "${APP_USER}" -g "${APP_USER}" -m 0640 "${SCRIPT_DIR}/.env.prod.example" "${ENV_FILE}"
# Auto-fill the two REQUIRED secrets so first boot doesn't crash.
SECRET="$(openssl rand -hex 32)"
INIT_KEY="$(openssl rand -hex 24)"
SETT_KEY="$(openssl rand -hex 24)"
AUD_KEY="$(openssl rand -hex 24)"
DB_PASSWORD="$(openssl rand -hex 24)"
DB_PASSWORD_SQL="$(sql_escape "${DB_PASSWORD}")"
API_KEYS_VALUE="${INIT_KEY}:initiator,${SETT_KEY}:settler,${AUD_KEY}:auditor"
DATABASE_URL="postgresql://${APP_USER}:${DB_PASSWORD}@127.0.0.1:5432/${APP_USER}"
log "setting Postgres password for role ${APP_USER}"
run "sudo -u postgres psql -c \"ALTER ROLE ${APP_USER} WITH LOGIN PASSWORD '${DB_PASSWORD_SQL}';\""
run "sed -i 's|^EVENT_SIGNING_SECRET=.*|EVENT_SIGNING_SECRET=${SECRET}|' '${ENV_FILE}'"
run "sed -i 's|^API_KEYS=.*|API_KEYS=${API_KEYS_VALUE}|' '${ENV_FILE}'"
run "sed -i 's|^DATABASE_URL=.*|DATABASE_URL=${DATABASE_URL}|' '${ENV_FILE}'"
run "grep -q '^ORCHESTRATOR_API_KEYS=' '${ENV_FILE}' && sed -i 's|^ORCHESTRATOR_API_KEYS=.*|ORCHESTRATOR_API_KEYS=${API_KEYS_VALUE}|' '${ENV_FILE}' || printf '\nORCHESTRATOR_API_KEYS=%s\n' '${API_KEYS_VALUE}' >> '${ENV_FILE}'"
# Write a root-only handoff file so ops can grab the keys without
# scraping journald or reading the env file. The canonical copy lives
# in ${ENV_FILE}; delete this file once the keys are in your password
# manager.
if [[ "${DRY_RUN}" -eq 0 ]]; then
umask 077
cat > "${FIRST_KEYS_FILE}" <<EOF
# CurrenciCombo first-deploy secrets — generated $(date -Iseconds) by install.sh
#
# This file contains the initial API keys and event-signing secret for the
# orchestrator. The canonical live values live in ${ENV_FILE} and are what
# systemd actually loads. This file is a root-only handoff copy — record
# these values in your password manager, then:
#
# shred -u ${FIRST_KEYS_FILE}
#
# Re-running install.sh does NOT regenerate these values if ${ENV_FILE}
# already exists. Losing both ${FIRST_KEYS_FILE} and ${ENV_FILE} means
# rotating all three API keys and the signing secret.
EVENT_SIGNING_SECRET=${SECRET}
ORCHESTRATOR_API_KEY_INITIATOR=${INIT_KEY}
ORCHESTRATOR_API_KEY_SETTLER=${SETT_KEY}
ORCHESTRATOR_API_KEY_AUDITOR=${AUD_KEY}
DATABASE_URL=${DATABASE_URL}
# As it appears in ${ENV_FILE}:
API_KEYS=${API_KEYS_VALUE}
ORCHESTRATOR_API_KEYS=${API_KEYS_VALUE}
EOF
chmod 0600 "${FIRST_KEYS_FILE}"
chown root:root "${FIRST_KEYS_FILE}"
else
log "[dry-run] would write ${FIRST_KEYS_FILE} (0600, root:root)"
fi
log " generated EVENT_SIGNING_SECRET (64 hex)"
log " generated 3 API keys (initiator/settler/auditor)"
log " generated local Postgres password for ${APP_USER}"
log " initial secrets written to ${FIRST_KEYS_FILE} (0600) — record in password manager, then 'shred -u ${FIRST_KEYS_FILE}'"
fi
# ----------------------------------------------------------------------
# 6. webapp-nginx.conf
# ----------------------------------------------------------------------
run install -o "${APP_USER}" -g "${APP_USER}" -m 0644 \
"${SCRIPT_DIR}/webapp-nginx.conf" "${NGINX_FILE}"
# ----------------------------------------------------------------------
# 7. systemd units
# ----------------------------------------------------------------------
run install -o root -g root -m 0644 \
"${SCRIPT_DIR}/systemd/currencicombo-orchestrator.service" \
"${SYSTEMD_DIR}/currencicombo-orchestrator.service"
run install -o root -g root -m 0644 \
"${SCRIPT_DIR}/systemd/currencicombo-webapp.service" \
"${SYSTEMD_DIR}/currencicombo-webapp.service"
run systemctl daemon-reload
# ----------------------------------------------------------------------
# 8. Enable (but do NOT start yet — no build exists)
# ----------------------------------------------------------------------
run systemctl enable currencicombo-orchestrator.service
run systemctl enable currencicombo-webapp.service
log "install complete."
log " next: run scripts/deployment/deploy-currencicombo-8604.sh as root to build + start."

View File

@@ -1,34 +0,0 @@
[Unit]
Description=CurrenciCombo orchestrator (Node)
Documentation=https://gitea.d-bis.org/d-bis/CurrenciCombo
After=network-online.target postgresql.service redis-server.service redis.service
Wants=network-online.target
[Service]
Type=simple
User=currencicombo
Group=currencicombo
WorkingDirectory=/opt/currencicombo/orchestrator
EnvironmentFile=/etc/currencicombo/orchestrator.env
ExecStart=/usr/bin/node /opt/currencicombo/orchestrator/dist/index.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
StandardOutput=journal
StandardError=journal
SyslogIdentifier=currencicombo-orchestrator
# Hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/log/currencicombo
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
[Install]
WantedBy=multi-user.target

View File

@@ -1,34 +0,0 @@
[Unit]
Description=CurrenciCombo webapp (Vite SPA served by nginx)
Documentation=https://gitea.d-bis.org/d-bis/CurrenciCombo
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=currencicombo
Group=currencicombo
RuntimeDirectory=currencicombo-webapp
RuntimeDirectoryMode=0755
ExecStart=/usr/sbin/nginx -c /etc/currencicombo/webapp-nginx.conf -g 'daemon off; pid /run/currencicombo-webapp/nginx.pid;'
ExecReload=/usr/sbin/nginx -c /etc/currencicombo/webapp-nginx.conf -s reload
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=currencicombo-webapp
# Hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/log/currencicombo /run/currencicombo-webapp
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
[Install]
WantedBy=multi-user.target

View File

@@ -1,80 +0,0 @@
# Self-contained nginx.conf for the CurrenciCombo Vite SPA.
# Invoked by the `currencicombo-webapp.service` systemd unit and installed
# to /etc/currencicombo/webapp-nginx.conf by scripts/deployment/install.sh.
#
# Listens on :3000 (NPMplus upstream). NPMplus path-routes /api/* to the
# orchestrator on :8080 (with SSE-friendly settings — see README.md);
# everything else lands here.
# This config does NOT proxy /api itself — that's intentional so a wrong
# NPMplus rule fails loudly instead of silently bypassing the orchestrator.
worker_processes auto;
error_log /var/log/currencicombo/webapp-nginx.error.log warn;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/currencicombo/webapp-nginx.access.log combined;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
server_tokens off;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Uploads/bodies: the portal is a static SPA, so any request with a body
# is almost certainly mis-routed. Cap tight.
client_max_body_size 1m;
server {
listen 3000 default_server;
listen [::]:3000 default_server;
server_name _;
root /opt/currencicombo/webapp/dist;
index index.html;
# Security headers are also set by NPMplus, but apply them here too
# so they survive a direct-to-CT curl for debugging.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Immutable asset bundles.
location /assets/ {
access_log off;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Deny sourcemaps in prod.
location ~ \.map$ {
access_log off;
deny all;
return 404;
}
# Guard-rail: if NPMplus fails to path-route /api/*, surface it as a
# clean 421 rather than serving index.html and confusing the browser
# with a JSON parse error. The SSE endpoint lives at
# /api/plans/:id/events/stream, which also sits under /api/, so one
# rule covers both.
location /api/ {
return 421 "NPMplus is misconfigured: /api/* must proxy to orchestrator :8080\n";
add_header Content-Type text/plain always;
}
# SPA fallback. Must come last.
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

@@ -1,619 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { addEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type NodeChange, type EdgeChange } from '@xyflow/react';
import TitleBar from './components/TitleBar';
import ActivityBar from './components/ActivityBar';
import LeftPanel from './components/LeftPanel';
import Canvas from './components/Canvas';
import RightPanel from './components/RightPanel';
import BottomPanel from './components/BottomPanel';
import CommandPalette from './components/CommandPalette';
import { useBreakpoint } from './hooks/useBreakpoint';
import type { ActivityTab, SessionMode, ComponentItem, HistoryEntry, TransactionTab, TerminalEntry, AuditEntry, ValidationIssue } from './types';
const STORAGE_KEY = 'transactflow-workspace';
/* The workspace (IDE-style multi-panel + react-flow canvas) is designed
* for ≥ md viewports. Phones get a friendly "open on a larger screen"
* screen with direct links into the portal routes. This matches the
* pattern used by VS Code Web, Figma, Replit, etc.
*/
function WorkspaceMobileGate() {
return (
<section className="workspace-mobile-gate" aria-labelledby="wsgate-h">
<h2 id="wsgate-h">Transaction Builder is designed for larger screens</h2>
<p>
The Transaction Builder workspace uses a multi-panel IDE layout that
needs more room than a phone can comfortably provide. Please open this
workspace on a tablet in landscape or on a laptop / desktop.
</p>
<p>You can still use the rest of the portal on this device:</p>
<div className="cta-row">
<Link to="/dashboard" className="cta primary">Go to Overview</Link>
<Link to="/transactions" className="cta">View Transactions</Link>
<Link to="/accounts" className="cta">Accounts</Link>
</div>
</section>
);
}
export default function AppWithMobileGate() {
const { isMobile } = useBreakpoint();
return (
<>
{isMobile && <WorkspaceMobileGate />}
{!isMobile && <WorkspaceApp />}
</>
);
}
function loadWorkspace() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
return null;
}
function saveWorkspace(state: Record<string, unknown>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* ignore */ }
}
function WorkspaceApp() {
const saved = useRef(loadWorkspace());
const [activityTab, setActivityTab] = useState<ActivityTab>(saved.current?.activityTab || 'builder');
const [leftOpen, setLeftOpen] = useState(saved.current?.leftOpen ?? true);
const [rightOpen, setRightOpen] = useState(saved.current?.rightOpen ?? true);
const [bottomOpen, setBottomOpen] = useState(saved.current?.bottomOpen ?? true);
const [bottomExpanded, setBottomExpanded] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [mode, setMode] = useState<SessionMode>(saved.current?.mode || 'Sandbox');
const [leftWidth, setLeftWidth] = useState(saved.current?.leftWidth ?? 280);
const [rightWidth, setRightWidth] = useState(saved.current?.rightWidth ?? 320);
const [bottomHeight, setBottomHeight] = useState(saved.current?.bottomHeight ?? 220);
// Transaction tabs
const [transactionTabs, setTransactionTabs] = useState<TransactionTab[]>([
{ id: 'tx-1', name: 'Untitled Transaction', nodes: [], edges: [] },
]);
const [activeTransactionId, setActiveTransactionId] = useState('tx-1');
const activeTransaction = transactionTabs.find(t => t.id === activeTransactionId)!;
const nodes = activeTransaction.nodes;
const edges = activeTransaction.edges;
const setNodes = useCallback((updater: Node[] | ((prev: Node[]) => Node[])) => {
setTransactionTabs(prev => prev.map(t => {
if (t.id !== activeTransactionId) return t;
const newNodes = typeof updater === 'function' ? updater(t.nodes) : updater;
return { ...t, nodes: newNodes };
}));
}, [activeTransactionId]);
const setEdges = useCallback((updater: Edge[] | ((prev: Edge[]) => Edge[])) => {
setTransactionTabs(prev => prev.map(t => {
if (t.id !== activeTransactionId) return t;
const newEdges = typeof updater === 'function' ? updater(t.edges) : updater;
return { ...t, edges: newEdges };
}));
}, [activeTransactionId]);
// Undo/redo
const [history, setHistory] = useState<HistoryEntry[]>([{ nodes: [], edges: [] }]);
const [historyIndex, setHistoryIndex] = useState(0);
const skipHistoryRef = useRef(false);
const pushHistory = useCallback((n: Node[], e: Edge[]) => {
if (skipHistoryRef.current) { skipHistoryRef.current = false; return; }
setHistory(prev => {
const trimmed = prev.slice(0, historyIndex + 1);
const entry = { nodes: JSON.parse(JSON.stringify(n)), edges: JSON.parse(JSON.stringify(e)) };
const next = [...trimmed, entry];
if (next.length > 50) next.shift();
return next;
});
setHistoryIndex(prev => Math.min(prev + 1, 50));
}, [historyIndex]);
const undo = useCallback(() => {
if (historyIndex <= 0) return;
const newIndex = historyIndex - 1;
const entry = history[newIndex];
if (!entry) return;
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyIndex, history, setNodes, setEdges]);
const redo = useCallback(() => {
if (historyIndex >= history.length - 1) return;
const newIndex = historyIndex + 1;
const entry = history[newIndex];
if (!entry) return;
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyIndex, history, setNodes, setEdges]);
// Selected nodes
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const selectedNodes = nodes.filter(n => selectedNodeIds.has(n.id));
// Recent components
const [recentComponents, setRecentComponents] = useState<string[]>([]);
const addRecentComponent = useCallback((id: string) => {
setRecentComponents(prev => {
const next = [id, ...prev.filter(x => x !== id)];
return next.slice(0, 20);
});
}, []);
// Terminal log entries (live)
const [terminalEntries, setTerminalEntries] = useState<TerminalEntry[]>([]);
const addTerminalEntry = useCallback((level: TerminalEntry['level'], source: string, message: string) => {
setTerminalEntries(prev => [...prev, {
id: Date.now().toString(),
timestamp: new Date(),
level,
source,
message,
}]);
}, []);
// Audit entries (live)
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const addAuditEntry = useCallback((action: string, detail: string) => {
setAuditEntries(prev => [...prev, {
id: Date.now().toString(),
timestamp: new Date(),
user: 'user',
action,
detail,
}]);
}, []);
// Validation state
const [validationIssues, setValidationIssues] = useState<ValidationIssue[]>([]);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationResults, setSimulationResults] = useState<string | null>(null);
// Split view
const [splitView, setSplitView] = useState(false);
// Resizable panels
const resizing = useRef<{ side: 'left' | 'right' | 'bottom'; startPos: number; startSize: number } | null>(null);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!resizing.current) return;
const { side, startPos, startSize } = resizing.current;
if (side === 'left') {
const delta = e.clientX - startPos;
setLeftWidth(Math.max(200, Math.min(500, startSize + delta)));
} else if (side === 'right') {
const delta = startPos - e.clientX;
setRightWidth(Math.max(240, Math.min(600, startSize + delta)));
} else if (side === 'bottom') {
const delta = startPos - e.clientY;
setBottomHeight(Math.max(120, Math.min(500, startSize + delta)));
}
};
const onMouseUp = () => { resizing.current = null; document.body.style.cursor = ''; document.body.style.userSelect = ''; };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); };
}, []);
const startResize = useCallback((side: 'left' | 'right' | 'bottom', e: React.MouseEvent) => {
resizing.current = {
side,
startPos: side === 'bottom' ? e.clientY : e.clientX,
startSize: side === 'left' ? leftWidth : side === 'right' ? rightWidth : bottomHeight,
};
document.body.style.cursor = side === 'bottom' ? 'row-resize' : 'col-resize';
document.body.style.userSelect = 'none';
}, [leftWidth, rightWidth, bottomHeight]);
// Persist workspace
useEffect(() => {
saveWorkspace({ leftOpen, rightOpen, bottomOpen, leftWidth, rightWidth, bottomHeight, mode, activityTab });
}, [leftOpen, rightOpen, bottomOpen, leftWidth, rightWidth, bottomHeight, mode, activityTab]);
// Toggles
const toggleLeft = useCallback(() => setLeftOpen((p: boolean) => !p), []);
const toggleRight = useCallback(() => setRightOpen((p: boolean) => !p), []);
const toggleBottom = useCallback(() => setBottomOpen((p: boolean) => !p), []);
const toggleCommandPalette = useCallback(() => setCommandPaletteOpen((p: boolean) => !p), []);
// Node operations
const nodeIdCounter = useRef(0);
const onDropComponent = useCallback((item: ComponentItem, position: { x: number; y: number }) => {
const newNode: Node = {
id: `node_${nodeIdCounter.current++}`,
type: 'transactionNode',
position,
data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined },
};
setNodes(prev => {
const next = [...prev, newNode];
pushHistory(next, edges);
return next;
});
addRecentComponent(item.id);
addTerminalEntry('info', 'canvas', `Node added: ${item.label}`);
addAuditEntry('NODE_ADD', `Added ${item.label} node to canvas`);
}, [setNodes, edges, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
const onConnect = useCallback((params: Connection) => {
setEdges(prev => {
const next = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, prev);
pushHistory(nodes, next);
return next;
});
addTerminalEntry('info', 'canvas', `Edge connected: ${params.source}${params.target}`);
addAuditEntry('EDGE_CREATE', `Connection created`);
}, [setEdges, nodes, pushHistory, addTerminalEntry, addAuditEntry]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((prev: Node[]) => applyNodeChanges(changes, prev));
}, [setNodes]);
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
setEdges((prev: Edge[]) => applyEdgeChanges(changes, prev));
}, [setEdges]);
const onSelectionChange = useCallback(({ nodes: selectedNodes }: { nodes: Node[] }) => {
setSelectedNodeIds(new Set(selectedNodes.map(n => n.id)));
}, []);
const deleteSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return;
setNodes(prev => {
const next = prev.filter(n => !selectedNodeIds.has(n.id));
pushHistory(next, edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
return next;
});
setEdges(prev => prev.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target)));
addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`);
addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`);
setSelectedNodeIds(new Set());
}, [selectedNodeIds, setNodes, setEdges, edges, pushHistory, addTerminalEntry, addAuditEntry]);
const duplicateSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return;
const selected = nodes.filter(n => selectedNodeIds.has(n.id));
const newNodes = selected.map(n => ({
...n,
id: `node_${nodeIdCounter.current++}`,
position: { x: n.position.x + 40, y: n.position.y + 40 },
selected: false,
}));
setNodes(prev => {
const next = [...prev, ...newNodes];
pushHistory(next, edges);
return next;
});
addTerminalEntry('info', 'canvas', `Duplicated ${selected.length} node(s)`);
addAuditEntry('NODE_DUPLICATE', `Duplicated ${selected.length} node(s)`);
}, [selectedNodeIds, nodes, setNodes, edges, pushHistory, addTerminalEntry, addAuditEntry]);
// Validate
const runValidation = useCallback(() => {
const issues: ValidationIssue[] = [];
if (nodes.length === 0) {
issues.push({ id: 'v1', severity: 'info', message: 'Graph is empty. Add components to begin validation.' });
} else {
const disconnected = nodes.filter(n => !edges.some(e => e.source === n.id || e.target === n.id));
disconnected.forEach(n => {
const d = n.data as Record<string, unknown>;
issues.push({ id: `v-disc-${n.id}`, severity: 'warning', node: (d.label as string) || n.id, message: 'Node is not connected to any other node' });
});
const hasCompliance = nodes.some(n => (n.data as Record<string, unknown>).category === 'compliance');
if (!hasCompliance) {
issues.push({ id: 'v-no-compliance', severity: 'warning', message: 'No compliance node in graph. Consider adding KYC/AML checks.' });
}
const sources = nodes.filter(n => !edges.some(e => e.target === n.id));
const sinks = nodes.filter(n => !edges.some(e => e.source === n.id));
if (sources.length === 0) issues.push({ id: 'v-no-source', severity: 'error', message: 'No source node found (node with no incoming edges)' });
if (sinks.length === 0) issues.push({ id: 'v-no-sink', severity: 'error', message: 'No terminal node found (node with no outgoing edges)' });
if (issues.filter(i => i.severity === 'error').length === 0) {
issues.push({ id: 'v-ok', severity: 'info', message: `Validation passed. ${nodes.length} nodes, ${edges.length} connections verified.` });
}
}
setValidationIssues(issues);
const errs = issues.filter(i => i.severity === 'error').length;
const warns = issues.filter(i => i.severity === 'warning').length;
addTerminalEntry(errs > 0 ? 'error' : warns > 0 ? 'warn' : 'success', 'validation', `Validation complete: ${errs} errors, ${warns} warnings`);
addAuditEntry('VALIDATION_RUN', `Validation: ${errs} errors, ${warns} warnings`);
// Update node statuses
setNodes(prev => prev.map(n => {
const nodeIssues = issues.filter(i => i.node === ((n.data as Record<string, unknown>).label as string));
const hasError = nodeIssues.some(i => i.severity === 'error');
const hasWarning = nodeIssues.some(i => i.severity === 'warning');
return {
...n,
data: { ...n.data, status: hasError ? 'error' : hasWarning ? 'warning' : (nodes.length > 0 ? 'valid' : undefined) },
};
}));
return issues;
}, [nodes, edges, addTerminalEntry, addAuditEntry, setNodes]);
// Simulate
const runSimulation = useCallback(() => {
if (nodes.length === 0) {
addTerminalEntry('warn', 'simulation', 'Cannot simulate empty graph');
return;
}
setIsSimulating(true);
addTerminalEntry('info', 'simulation', 'Starting transaction simulation...');
addAuditEntry('SIMULATION_START', 'Simulation initiated');
setTimeout(() => {
const hasCompliance = nodes.some(n => (n.data as Record<string, unknown>).category === 'compliance');
const routingNodes = nodes.filter(n => (n.data as Record<string, unknown>).category === 'routing');
const fee = (Math.random() * 0.1).toFixed(4);
const results = [
`Simulation complete for ${nodes.length} nodes, ${edges.length} edges`,
`Estimated fees: $${fee}%`,
`Settlement window: T+${routingNodes.length > 0 ? '1' : '2'}`,
`Compliance: ${hasCompliance ? 'All checks passed' : 'WARNING - No compliance checks in flow'}`,
`Routing: ${routingNodes.length} venue(s) evaluated`,
`Status: ${hasCompliance ? 'READY FOR EXECUTION' : 'REVIEW REQUIRED'}`,
].join('\n');
setSimulationResults(results);
setIsSimulating(false);
addTerminalEntry('success', 'simulation', 'Simulation completed successfully');
addAuditEntry('SIMULATION_COMPLETE', `Simulation: ${nodes.length} nodes processed`);
}, 1500);
}, [nodes, edges, addTerminalEntry, addAuditEntry]);
// Execute
const runExecution = useCallback(() => {
if (nodes.length === 0) {
addTerminalEntry('warn', 'execution', 'Cannot execute empty graph');
return;
}
if (mode !== 'Live') {
addTerminalEntry('info', 'execution', `Transaction submitted in ${mode} mode`);
} else {
addTerminalEntry('warn', 'execution', 'Live execution initiated — awaiting confirmation');
}
addAuditEntry('EXECUTE', `Transaction submitted in ${mode} mode`);
addTerminalEntry('success', 'execution', `Transaction ${activeTransaction.name} dispatched to settlement queue`);
}, [nodes, mode, activeTransaction.name, addTerminalEntry, addAuditEntry]);
// Transaction tab management
const addTransactionTab = useCallback(() => {
const id = `tx-${Date.now()}`;
setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]);
setActiveTransactionId(id);
setHistory([{ nodes: [], edges: [] }]);
setHistoryIndex(0);
addTerminalEntry('info', 'system', 'New transaction tab created');
}, [addTerminalEntry]);
const closeTransactionTab = useCallback((id: string) => {
if (transactionTabs.length <= 1) return;
setTransactionTabs(prev => prev.filter(t => t.id !== id));
if (activeTransactionId === id) {
const remaining = transactionTabs.filter(t => t.id !== id);
setActiveTransactionId(remaining[0].id);
}
}, [transactionTabs, activeTransactionId]);
const renameTransaction = useCallback((name: string) => {
setTransactionTabs(prev => prev.map(t => t.id === activeTransactionId ? { ...t, name } : t));
}, [activeTransactionId]);
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && e.key === 'k') { e.preventDefault(); toggleCommandPalette(); }
if (ctrl && e.key === 'b') { e.preventDefault(); toggleLeft(); }
if (ctrl && e.key === 'j') { e.preventDefault(); toggleRight(); }
if (ctrl && e.key === '`') { e.preventDefault(); toggleBottom(); }
if (ctrl && e.shiftKey && e.key === 'V') { e.preventDefault(); runValidation(); }
if (ctrl && e.shiftKey && e.key === 'S') { e.preventDefault(); runSimulation(); }
if (ctrl && e.shiftKey && e.key === 'E') { e.preventDefault(); runExecution(); }
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); redo(); }
if (ctrl && e.key === 'd') { e.preventDefault(); duplicateSelectedNodes(); }
if (e.key === 'Delete' || e.key === 'Backspace') {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return;
if (selectedNodeIds.size > 0) { e.preventDefault(); deleteSelectedNodes(); }
}
if (e.key === 'Escape' && commandPaletteOpen) { setCommandPaletteOpen(false); }
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [commandPaletteOpen, toggleCommandPalette, toggleLeft, toggleRight, toggleBottom, runValidation, runSimulation, runExecution, undo, redo, duplicateSelectedNodes, deleteSelectedNodes, selectedNodeIds]);
// Focus chat
const chatInputRef = useRef<HTMLInputElement>(null);
const focusChat = useCallback(() => { setRightOpen(true); setTimeout(() => chatInputRef.current?.focus(), 100); }, []);
const focusTerminal = useCallback(() => { setBottomOpen(true); }, []);
return (
<div className="app-shell">
<TitleBar
mode={mode}
onModeChange={setMode}
onToggleCommandPalette={toggleCommandPalette}
onValidate={runValidation}
onSimulate={runSimulation}
onExecute={runExecution}
/>
<div className="app-body">
<ActivityBar
activeTab={activityTab}
onTabChange={setActivityTab}
leftPanelOpen={leftOpen}
onToggleLeftPanel={toggleLeft}
/>
<div className="workspace">
<div className="workspace-upper">
{leftOpen && (
<LeftPanel
width={leftWidth}
activityTab={activityTab}
recentComponents={recentComponents}
/>
)}
{leftOpen && (
<div
className="panel-divider vertical"
onMouseDown={e => startResize('left', e)}
/>
)}
<div className="canvas-region">
<Canvas
nodes={nodes}
edges={edges}
setNodes={setNodes}
setEdges={setEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onSelectionChange={onSelectionChange}
onDropComponent={onDropComponent}
onValidate={runValidation}
onSimulate={runSimulation}
onExecute={runExecution}
transactionName={activeTransaction.name}
onRenameTransaction={renameTransaction}
isSimulating={isSimulating}
simulationResults={simulationResults}
onDismissSimulation={() => setSimulationResults(null)}
mode={mode}
canUndo={historyIndex > 0}
canRedo={historyIndex < history.length - 1}
onUndo={undo}
onRedo={redo}
selectedNodeIds={selectedNodeIds}
onDeleteSelected={deleteSelectedNodes}
onDuplicateSelected={duplicateSelectedNodes}
transactionTabs={transactionTabs}
activeTransactionId={activeTransactionId}
onSwitchTab={setActiveTransactionId}
onAddTab={addTransactionTab}
onCloseTab={closeTransactionTab}
splitView={splitView}
onToggleSplitView={() => setSplitView(p => !p)}
pushHistory={pushHistory}
/>
</div>
{rightOpen && (
<div
className="panel-divider vertical"
onMouseDown={e => startResize('right', e)}
/>
)}
{rightOpen && (
<RightPanel
width={rightWidth}
nodes={nodes}
edges={edges}
selectedNodes={selectedNodes}
chatInputRef={chatInputRef}
onInsertBlock={onDropComponent}
onRunValidation={runValidation}
onOptimizeRoute={() => {
addTerminalEntry('info', 'routing', 'Route optimization started...');
setTimeout(() => addTerminalEntry('success', 'routing', 'Optimal route found: Banking Rail → SWIFT Gateway (0.02% fee)'), 800);
}}
onRunCompliance={() => {
addTerminalEntry('info', 'compliance', 'Running compliance pass...');
const hasCompliance = nodes.some(n => (n.data as Record<string, unknown>).category === 'compliance');
setTimeout(() => addTerminalEntry(hasCompliance ? 'success' : 'warn', 'compliance', hasCompliance ? 'All compliance checks passed' : 'No compliance nodes found in graph'), 600);
}}
onGenerateSettlement={() => {
addTerminalEntry('info', 'settlement', 'Generating settlement message...');
setTimeout(() => addTerminalEntry('success', 'settlement', 'Settlement instruction generated: pacs.008 message ready'), 700);
}}
/>
)}
</div>
{bottomOpen && (
<div
className="panel-divider horizontal"
onMouseDown={e => startResize('bottom', e)}
/>
)}
{bottomOpen && (
<BottomPanel
height={bottomHeight}
isExpanded={bottomExpanded}
onToggleExpand={() => setBottomExpanded(p => !p)}
terminalEntries={terminalEntries}
auditEntries={auditEntries}
validationIssues={validationIssues}
/>
)}
</div>
</div>
<div className="status-bar">
<div className="status-bar-left">
<span className="status-item">
<span className="status-dot green" /> Connected
</span>
<span className="status-item">{mode}</span>
<span className="status-item">Multi-Jurisdiction</span>
</div>
<div className="status-bar-right">
<span className="status-item">Compliance: Active</span>
<span className="status-item">ISO-20022: Ready</span>
<span className="status-item">Routing: 12 venues</span>
<span className="status-item">
<kbd className="status-kbd">Ctrl+K</kbd> Command Palette
</span>
</div>
</div>
<CommandPalette
isOpen={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onToggleLeft={toggleLeft}
onToggleRight={toggleRight}
onToggleBottom={toggleBottom}
onValidate={runValidation}
onSimulate={runSimulation}
onExecute={runExecution}
onNewTransaction={addTransactionTab}
onFocusChat={focusChat}
onFocusTerminal={focusTerminal}
onRunCompliance={() => {
addTerminalEntry('info', 'compliance', 'Running compliance pass...');
setTimeout(() => addTerminalEntry('success', 'compliance', 'Compliance pass completed'), 600);
}}
onOptimizeRoute={() => {
addTerminalEntry('info', 'routing', 'Optimizing routes...');
setTimeout(() => addTerminalEntry('success', 'routing', 'Routes optimized'), 600);
}}
onGenerateISO={() => {
addTerminalEntry('info', 'iso20022', 'Generating ISO-20022 message...');
setTimeout(() => addTerminalEntry('success', 'iso20022', 'pain.001 message generated'), 700);
}}
onExportAudit={() => {
addTerminalEntry('info', 'audit', 'Exporting audit summary...');
setTimeout(() => addTerminalEntry('success', 'audit', 'Audit summary exported'), 500);
}}
onSearchComponents={() => { setLeftOpen(true); setActivityTab('builder'); }}
/>
</div>
);
}

View File

@@ -1,274 +0,0 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import AccountsPage from './pages/AccountsPage';
import TreasuryPage from './pages/TreasuryPage';
import ReportingPage from './pages/ReportingPage';
import CompliancePage from './pages/CompliancePage';
import SettlementsPage from './pages/SettlementsPage';
import TransactionsPage from './pages/TransactionsPage';
import PortalLayout from './components/portal/PortalLayout';
import LiveChainBanner from './components/portal/LiveChainBanner';
import SkipToContent from './components/a11y/SkipToContent';
import App from './App';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="portal-loading">
<div className="portal-loading-spinner" />
<span>Initializing secure session...</span>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export default function Portal() {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="portal-loading">
<div className="portal-loading-spinner" />
<span>Initializing secure session...</span>
</div>
);
}
return (
<>
<SkipToContent targetId="main-content" />
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />}
/>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<PortalLayout>
<DashboardPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/transaction-builder"
element={
<ProtectedRoute>
<PortalLayout>
<div className="transaction-builder-module" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<LiveChainBanner />
<div style={{ flex: 1, minHeight: 0 }}>
<App />
</div>
</div>
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/accounts"
element={
<ProtectedRoute>
<PortalLayout>
<AccountsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/treasury"
element={
<ProtectedRoute>
<PortalLayout>
<TreasuryPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/reporting"
element={
<ProtectedRoute>
<PortalLayout>
<ReportingPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/compliance"
element={
<ProtectedRoute>
<PortalLayout>
<CompliancePage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/settlements"
element={
<ProtectedRoute>
<PortalLayout>
<SettlementsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/transactions"
element={
<ProtectedRoute>
<PortalLayout>
<TransactionsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/transactions/:planId"
element={
<ProtectedRoute>
<PortalLayout>
<TransactionsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<PortalLayout>
<SettingsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />} />
</Routes>
</>
);
}
function SettingsPage() {
const { user, wallet } = useAuth();
return (
<div className="settings-page">
<div className="page-header">
<h1>Settings</h1>
<p className="page-subtitle">Portal configuration and user preferences</p>
</div>
<div className="settings-grid">
<div className="dashboard-card">
<div className="card-header"><h3>Profile</h3></div>
<div className="settings-section">
<div className="setting-row">
<span className="setting-label">Display Name</span>
<span className="setting-value">{user?.displayName || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Role</span>
<span className="setting-value">{user?.role?.replace('_', ' ') || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Institution</span>
<span className="setting-value">{user?.institution || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Department</span>
<span className="setting-value">{user?.department || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Wallet Address</span>
<span className="setting-value mono">{wallet?.address || '—'}</span>
</div>
<div className="setting-row">
<span className="setting-label">Chain ID</span>
<span className="setting-value">{wallet?.chainId || '—'}</span>
</div>
</div>
</div>
<div className="dashboard-card">
<div className="card-header"><h3>Permissions</h3></div>
<div className="settings-section">
<div className="permissions-list">
{user?.permissions?.map(p => (
<span key={p} className="permission-badge">{p}</span>
)) || <span>No permissions</span>}
</div>
</div>
</div>
<div className="dashboard-card">
<div className="card-header"><h3>Reporting Preferences</h3></div>
<div className="settings-section">
<div className="setting-row">
<span className="setting-label">Default Standard</span>
<span className="setting-value">IFRS</span>
</div>
<div className="setting-row">
<span className="setting-label">Base Currency</span>
<span className="setting-value">USD</span>
</div>
<div className="setting-row">
<span className="setting-label">Fiscal Year End</span>
<span className="setting-value">December 31</span>
</div>
<div className="setting-row">
<span className="setting-label">Auto-generate Reports</span>
<span className="setting-value">Monthly</span>
</div>
</div>
</div>
<div className="dashboard-card">
<div className="card-header"><h3>Enterprise Controls</h3></div>
<div className="settings-section">
<div className="setting-row">
<span className="setting-label">Multi-signature Required</span>
<span className="setting-value">Yes (2-of-3)</span>
</div>
<div className="setting-row">
<span className="setting-label">Transaction Limit</span>
<span className="setting-value">$10,000,000</span>
</div>
<div className="setting-row">
<span className="setting-label">Approval Workflow</span>
<span className="setting-value">Dual Authorization</span>
</div>
<div className="setting-row">
<span className="setting-label">Session Timeout</span>
<span className="setting-value">30 minutes</span>
</div>
<div className="setting-row">
<span className="setting-label">Audit Logging</span>
<span className="setting-value">Enabled (Full)</span>
</div>
</div>
</div>
</div>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,77 +0,0 @@
import {
Blocks, Coins, LayoutTemplate, ShieldCheck, Route, Globe,
Bot, Terminal, History, Settings
} from 'lucide-react';
import type { ActivityTab } from '../types';
const tabs: { id: ActivityTab; icon: typeof Blocks; label: string }[] = [
{ id: 'builder', icon: Blocks, label: 'Builder' },
{ id: 'assets', icon: Coins, label: 'Assets' },
{ id: 'templates', icon: LayoutTemplate, label: 'Templates' },
{ id: 'compliance', icon: ShieldCheck, label: 'Compliance' },
{ id: 'routes', icon: Route, label: 'Routes' },
{ id: 'protocols', icon: Globe, label: 'Protocols' },
{ id: 'agents', icon: Bot, label: 'Agents' },
{ id: 'terminal', icon: Terminal, label: 'Terminal' },
{ id: 'audit', icon: History, label: 'Audit' },
{ id: 'settings', icon: Settings, label: 'Settings' },
];
interface ActivityBarProps {
activeTab: ActivityTab;
onTabChange: (tab: ActivityTab) => void;
leftPanelOpen: boolean;
onToggleLeftPanel: () => void;
}
export default function ActivityBar({ activeTab, onTabChange, leftPanelOpen, onToggleLeftPanel }: ActivityBarProps) {
return (
<div className="activity-bar">
<div className="activity-bar-top">
{tabs.slice(0, 7).map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
className={`activity-btn ${isActive && leftPanelOpen ? 'active' : ''}`}
title={tab.label}
onClick={() => {
if (isActive && leftPanelOpen) {
onToggleLeftPanel();
} else {
onTabChange(tab.id);
if (!leftPanelOpen) onToggleLeftPanel();
}
}}
>
<Icon size={20} />
</button>
);
})}
</div>
<div className="activity-bar-bottom">
{tabs.slice(7).map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className={`activity-btn ${activeTab === tab.id && leftPanelOpen ? 'active' : ''}`}
title={tab.label}
onClick={() => {
if (activeTab === tab.id && leftPanelOpen) {
onToggleLeftPanel();
} else {
onTabChange(tab.id);
if (!leftPanelOpen) onToggleLeftPanel();
}
}}
>
<Icon size={20} />
</button>
);
})}
</div>
</div>
);
}

View File

@@ -1,382 +0,0 @@
import { useState } from 'react';
import {
Terminal, ShieldCheck, Radio, History, Mail, Activity, Maximize2, Minimize2,
Search, Download, Filter, AlertOctagon, GitCompare
} from 'lucide-react';
import type { BottomTab, TerminalEntry, AuditEntry, ValidationIssue } from '../types';
import { sampleTerminal, sampleValidation, sampleAudit, sampleSettlement, sampleReconciliation, sampleExceptions, sampleMessageQueue, sampleEvents } from '../data/sampleData';
const tabs: { id: BottomTab; icon: typeof Terminal; label: string }[] = [
{ id: 'terminal', icon: Terminal, label: 'Terminal' },
{ id: 'validation', icon: ShieldCheck, label: 'Validation' },
{ id: '800system', icon: Radio, label: '800 System' },
{ id: 'settlement', icon: Activity, label: 'Settlement Queue' },
{ id: 'audit', icon: History, label: 'Audit Trail' },
{ id: 'messages', icon: Mail, label: 'Messages' },
{ id: 'events', icon: Activity, label: 'Events' },
{ id: 'reconciliation', icon: GitCompare, label: 'Reconciliation' },
{ id: 'exceptions', icon: AlertOctagon, label: 'Exceptions' },
];
interface BottomPanelProps {
height: number;
isExpanded: boolean;
onToggleExpand: () => void;
terminalEntries: TerminalEntry[];
auditEntries: AuditEntry[];
validationIssues: ValidationIssue[];
}
const statusColors: Record<string, string> = {
settled: '#22c55e',
pending: '#eab308',
in_review: '#3b82f6',
awaiting_approval: '#f97316',
dispatched: '#3b82f6',
partially_settled: '#a855f7',
failed: '#ef4444',
};
const levelColors: Record<string, string> = {
info: '#6b7280',
warn: '#eab308',
error: '#ef4444',
success: '#22c55e',
};
export default function BottomPanel({ height, isExpanded, onToggleExpand, terminalEntries, auditEntries, validationIssues }: BottomPanelProps) {
const [activeTab, setActiveTab] = useState<BottomTab>('terminal');
const [terminalFilter, setTerminalFilter] = useState('');
const [bottomSearch, setBottomSearch] = useState('');
const [showSearchBar, setShowSearchBar] = useState(false);
const [showFilterBar, setShowFilterBar] = useState(false);
const [levelFilter, setLevelFilter] = useState<string>('all');
const allTerminal = [...sampleTerminal, ...terminalEntries];
const filteredTerminal = allTerminal.filter(e => {
const matchesText = e.message.toLowerCase().includes(terminalFilter.toLowerCase()) ||
e.source.toLowerCase().includes(terminalFilter.toLowerCase());
const matchesLevel = levelFilter === 'all' || e.level === levelFilter;
return matchesText && matchesLevel;
});
const allAudit = [...sampleAudit, ...auditEntries];
const allValidation = validationIssues.length > 0 ? validationIssues : sampleValidation;
const handleExport = () => {
let content = '';
if (activeTab === 'terminal') {
content = allTerminal.map(e => `[${e.timestamp.toISOString()}] [${e.level}] [${e.source}] ${e.message}`).join('\n');
} else if (activeTab === 'audit') {
content = allAudit.map(e => `[${e.timestamp.toISOString()}] ${e.user} ${e.action}: ${e.detail}`).join('\n');
} else if (activeTab === 'validation') {
content = allValidation.map(e => `[${e.severity}] ${e.node ? e.node + ': ' : ''}${e.message}`).join('\n');
} else {
content = `Export of ${activeTab} tab data`;
}
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `transactflow-${activeTab}-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="bottom-panel" style={{ height: isExpanded ? '50vh' : height }}>
<div className="bottom-panel-header">
<div className="bottom-panel-tabs">
{tabs.map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className={`bottom-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<Icon size={13} />
<span>{tab.label}</span>
{tab.id === 'validation' && (
<span className="tab-badge info">{allValidation.length}</span>
)}
{tab.id === 'settlement' && (
<span className="tab-badge warn">{sampleSettlement.filter(s => s.status === 'pending').length}</span>
)}
{tab.id === 'exceptions' && (
<span className="tab-badge error">{sampleExceptions.length}</span>
)}
</button>
);
})}
</div>
<div className="bottom-panel-actions">
<button className="icon-btn-sm" title="Filter" onClick={() => { setShowFilterBar(!showFilterBar); setShowSearchBar(false); }}>
<Filter size={13} />
</button>
<button className="icon-btn-sm" title="Search" onClick={() => { setShowSearchBar(!showSearchBar); setShowFilterBar(false); }}>
<Search size={13} />
</button>
<button className="icon-btn-sm" title="Export" onClick={handleExport}>
<Download size={13} />
</button>
<button className="icon-btn-sm" title={isExpanded ? 'Minimize' : 'Maximize'} onClick={onToggleExpand}>
{isExpanded ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
</button>
</div>
</div>
{showSearchBar && (
<div className="bottom-search-bar">
<Search size={12} />
<input
type="text"
placeholder="Search in panel..."
value={bottomSearch}
onChange={e => setBottomSearch(e.target.value)}
autoFocus
/>
</div>
)}
{showFilterBar && activeTab === 'terminal' && (
<div className="bottom-filter-bar">
{['all', 'info', 'warn', 'error', 'success'].map(level => (
<button
key={level}
className={`filter-pill ${levelFilter === level ? 'active' : ''}`}
onClick={() => setLevelFilter(level)}
>
{level === 'all' ? 'All' : level.toUpperCase()}
</button>
))}
</div>
)}
<div className="bottom-panel-content">
{activeTab === 'terminal' && (
<div className="terminal-content">
<div className="terminal-filter">
<Search size={12} />
<input
type="text"
placeholder="Filter logs..."
value={terminalFilter}
onChange={e => setTerminalFilter(e.target.value)}
/>
</div>
<div className="terminal-entries">
{filteredTerminal.map(entry => (
<div key={entry.id} className="terminal-entry">
<span className="terminal-time">
{entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="terminal-level" style={{ color: levelColors[entry.level] }}>
[{entry.level.toUpperCase()}]
</span>
<span className="terminal-source">[{entry.source}]</span>
<span className="terminal-msg">{entry.message}</span>
</div>
))}
<div className="terminal-cursor">
<span className="cursor-blink"></span>
</div>
</div>
</div>
)}
{activeTab === 'validation' && (
<div className="validation-content">
{allValidation.map(issue => (
<div key={issue.id} className={`validation-entry ${issue.severity}`}>
<span className="validation-severity">{issue.severity.toUpperCase()}</span>
{issue.node && <span className="validation-node">{issue.node}</span>}
<span className="validation-msg">{issue.message}</span>
</div>
))}
</div>
)}
{activeTab === '800system' && (
<div className="system-800-content">
<div className="system-800-grid">
<div className="system-800-card">
<div className="system-800-card-header">Message Queue</div>
<div className="system-800-card-value">0 pending</div>
<div className="system-800-card-status healthy">Healthy</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">Core Banking</div>
<div className="system-800-card-value">Connected</div>
<div className="system-800-card-status healthy">Online</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">Ledger Feed</div>
<div className="system-800-card-value">0 postings/s</div>
<div className="system-800-card-status idle">Idle</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">SWIFT Gateway</div>
<div className="system-800-card-value">Ready</div>
<div className="system-800-card-status healthy">Online</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">ISO-20022 Engine</div>
<div className="system-800-card-value">3 schemas</div>
<div className="system-800-card-status healthy">Loaded</div>
</div>
<div className="system-800-card">
<div className="system-800-card-header">Retry Queue</div>
<div className="system-800-card-value">0 items</div>
<div className="system-800-card-status healthy">Clear</div>
</div>
</div>
</div>
)}
{activeTab === 'settlement' && (
<div className="settlement-content">
<table className="settlement-table">
<thead>
<tr>
<th>TX ID</th>
<th>Status</th>
<th>Amount</th>
<th>Asset</th>
<th>Counterparty</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{sampleSettlement.map(item => (
<tr key={item.id}>
<td className="mono">{item.txId}</td>
<td>
<span className="status-badge" style={{ color: statusColors[item.status], borderColor: statusColors[item.status] + '40' }}>
{item.status.replace(/_/g, ' ')}
</span>
</td>
<td className="mono">{item.amount}</td>
<td>{item.asset}</td>
<td>{item.counterparty}</td>
<td className="mono">{item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'audit' && (
<div className="audit-content">
{allAudit.map(entry => (
<div key={entry.id} className="audit-entry">
<span className="audit-time">
{entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="audit-user">{entry.user}</span>
<span className="audit-action">{entry.action}</span>
<span className="audit-detail">{entry.detail}</span>
</div>
))}
</div>
)}
{activeTab === 'messages' && (
<div className="messages-content">
<table className="settlement-table">
<thead>
<tr>
<th>Type</th>
<th>Direction</th>
<th>Counterparty</th>
<th>Status</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{sampleMessageQueue.map(msg => (
<tr key={msg.id}>
<td className="mono">{msg.msgType}</td>
<td>
<span className={`direction-badge ${msg.direction}`}>
{msg.direction === 'inbound' ? '← IN' : '→ OUT'}
</span>
</td>
<td>{msg.counterparty}</td>
<td>
<span className="status-badge" style={{ color: msg.status === 'sent' ? '#22c55e' : msg.status === 'received' ? '#3b82f6' : '#eab308' }}>
{msg.status}
</span>
</td>
<td className="mono">{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'events' && (
<div className="events-content">
{sampleEvents.map(evt => (
<div key={evt.id} className="audit-entry">
<span className="audit-time">
{evt.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="audit-action">{evt.type}</span>
<span className="audit-detail">{evt.detail}</span>
</div>
))}
</div>
)}
{activeTab === 'reconciliation' && (
<div className="reconciliation-content">
<table className="settlement-table">
<thead>
<tr>
<th>TX ID</th>
<th>Internal Ref</th>
<th>External Ref</th>
<th>Status</th>
<th>Amount</th>
<th>Asset</th>
</tr>
</thead>
<tbody>
{sampleReconciliation.map(item => (
<tr key={item.id}>
<td className="mono">{item.txId}</td>
<td className="mono">{item.internalRef}</td>
<td className="mono">{item.externalRef}</td>
<td>
<span className="status-badge" style={{ color: item.status === 'matched' ? '#22c55e' : '#ef4444' }}>
{item.status}
</span>
</td>
<td className="mono">{item.amount}</td>
<td>{item.asset}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'exceptions' && (
<div className="exceptions-content">
{sampleExceptions.map(exc => (
<div key={exc.id} className={`validation-entry ${exc.severity}`}>
<span className="validation-severity">{exc.severity.toUpperCase()}</span>
<span className="validation-node">{exc.txId}</span>
<span className="exception-type">{exc.type}</span>
<span className="validation-msg">{exc.message}</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,353 +0,0 @@
import { useCallback, useRef, useState, type DragEvent } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
type Connection,
type Node,
type Edge,
BackgroundVariant,
type OnNodesChange,
type OnEdgesChange,
useReactFlow,
ReactFlowProvider,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import TransactionNodeComponent from './TransactionNode';
import {
Save, GitBranch, ShieldCheck, FlaskConical, Play,
AlertTriangle, CheckCircle2, DollarSign, Clock, Globe,
Undo2, Redo2, Copy, Trash2, Plus, X, SplitSquareHorizontal,
ZoomIn, ZoomOut, Maximize
} from 'lucide-react';
import type { ComponentItem, TransactionTab, SessionMode } from '../types';
const nodeTypes = { transactionNode: TransactionNodeComponent };
interface CanvasProps {
nodes: Node[];
edges: Edge[];
setNodes: (updater: Node[] | ((prev: Node[]) => Node[])) => void;
setEdges: (updater: Edge[] | ((prev: Edge[]) => Edge[])) => void;
onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange;
onConnect: (params: Connection) => void;
onSelectionChange: (params: { nodes: Node[] }) => void;
onDropComponent: (item: ComponentItem, position: { x: number; y: number }) => void;
onValidate: () => void;
onSimulate: () => void;
onExecute: () => void;
transactionName: string;
onRenameTransaction: (name: string) => void;
isSimulating: boolean;
simulationResults: string | null;
onDismissSimulation: () => void;
mode: SessionMode;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
selectedNodeIds: Set<string>;
onDeleteSelected: () => void;
onDuplicateSelected: () => void;
transactionTabs: TransactionTab[];
activeTransactionId: string;
onSwitchTab: (id: string) => void;
onAddTab: () => void;
onCloseTab: (id: string) => void;
splitView: boolean;
onToggleSplitView: () => void;
pushHistory: (nodes: Node[], edges: Edge[]) => void;
}
function CanvasInner({
nodes, edges,
onNodesChange, onEdgesChange, onConnect, onSelectionChange, onDropComponent,
onValidate, onSimulate, onExecute,
transactionName, onRenameTransaction,
isSimulating, simulationResults, onDismissSimulation,
mode, canUndo, canRedo, onUndo, onRedo,
selectedNodeIds, onDeleteSelected, onDuplicateSelected,
transactionTabs, activeTransactionId, onSwitchTab, onAddTab, onCloseTab,
splitView, onToggleSplitView,
}: CanvasProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(transactionName);
const [zoomLevel, setZoomLevel] = useState(100);
const reactFlowInstance = useReactFlow();
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
const data = event.dataTransfer.getData('application/transactflow-component');
if (!data) return;
const item: ComponentItem = JSON.parse(data);
const wrapperBounds = reactFlowWrapper.current?.getBoundingClientRect();
if (!wrapperBounds) return;
const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
onDropComponent(item, position);
},
[onDropComponent, reactFlowInstance]
);
const onMoveEnd = useCallback(() => {
const zoom = reactFlowInstance.getZoom();
setZoomLevel(Math.round(zoom * 100));
}, [reactFlowInstance]);
const handleZoomIn = () => { reactFlowInstance.zoomIn(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
const handleZoomOut = () => { reactFlowInstance.zoomOut(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); };
const handleFitView = () => { reactFlowInstance.fitView(); setTimeout(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)), 100); };
const errorCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'error').length;
const warningCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'warning').length;
const commitName = () => {
setIsEditingName(false);
if (editName.trim()) onRenameTransaction(editName.trim());
else setEditName(transactionName);
};
return (
<div className="canvas-container">
{/* Transaction tabs */}
<div className="transaction-tabs">
{transactionTabs.map(tab => (
<div
key={tab.id}
className={`transaction-tab ${tab.id === activeTransactionId ? 'active' : ''}`}
onClick={() => onSwitchTab(tab.id)}
>
<span>{tab.name}</span>
{transactionTabs.length > 1 && (
<button className="tab-close" onClick={e => { e.stopPropagation(); onCloseTab(tab.id); }}>
<X size={10} />
</button>
)}
</div>
))}
<button className="transaction-tab-add" onClick={onAddTab} title="New Transaction">
<Plus size={12} />
</button>
</div>
<div className="canvas-header">
<div className="canvas-header-left">
{isEditingName ? (
<input
className="canvas-tx-name-input"
value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={commitName}
onKeyDown={e => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') { setEditName(transactionName); setIsEditingName(false); } }}
autoFocus
/>
) : (
<span
className="canvas-tx-name"
onClick={() => { setIsEditingName(true); setEditName(transactionName); }}
title="Click to rename"
>
{transactionName}
</span>
)}
<span className="canvas-version">v1.0</span>
<span className="canvas-save-state">
<Save size={12} /> Saved
</span>
<div className="canvas-toolbar-separator" />
<button className="canvas-toolbar-btn" onClick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)">
<Undo2 size={14} />
</button>
<button className="canvas-toolbar-btn" onClick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Y)">
<Redo2 size={14} />
</button>
<div className="canvas-toolbar-separator" />
<button className="canvas-toolbar-btn" onClick={onDuplicateSelected} disabled={selectedNodeIds.size === 0} title="Duplicate (Ctrl+D)">
<Copy size={14} />
</button>
<button className="canvas-toolbar-btn" onClick={onDeleteSelected} disabled={selectedNodeIds.size === 0} title="Delete (Del)">
<Trash2 size={14} />
</button>
<div className="canvas-toolbar-separator" />
<button className="canvas-toolbar-btn" onClick={onToggleSplitView} title="Split View">
<SplitSquareHorizontal size={14} />
</button>
</div>
<div className="canvas-header-center">
<button className="canvas-env-btn">
<GitBranch size={13} />
<span>{mode}</span>
</button>
</div>
<div className="canvas-header-right">
<button className="canvas-action-btn validate" onClick={onValidate}>
<ShieldCheck size={14} /> Validate
</button>
<button className="canvas-action-btn simulate" onClick={onSimulate} disabled={isSimulating}>
<FlaskConical size={14} /> {isSimulating ? 'Simulating...' : 'Simulate'}
</button>
<button className="canvas-action-btn execute" onClick={onExecute}>
<Play size={14} /> Execute
</button>
</div>
</div>
<div className="canvas-body" ref={reactFlowWrapper}>
<div style={{ display: 'flex', width: '100%', height: '100%' }}>
<div style={{ flex: 1, position: 'relative' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onSelectionChange={onSelectionChange}
onDragOver={onDragOver}
onDrop={onDrop}
onMoveEnd={onMoveEnd}
nodeTypes={nodeTypes}
fitView
snapToGrid
snapGrid={[16, 16]}
multiSelectionKeyCode="Shift"
deleteKeyCode={null}
defaultEdgeOptions={{
animated: true,
style: { stroke: '#3b82f6', strokeWidth: 2 },
}}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} color="#333" />
<Controls className="canvas-controls" showZoom={false} showFitView={false} showInteractive={false}>
<button className="react-flow__controls-button" onClick={handleZoomIn} title="Zoom In">
<ZoomIn size={14} />
</button>
<button className="react-flow__controls-button" onClick={handleZoomOut} title="Zoom Out">
<ZoomOut size={14} />
</button>
<button className="react-flow__controls-button zoom-display" title="Current Zoom">
{zoomLevel}%
</button>
<button className="react-flow__controls-button" onClick={handleFitView} title="Fit View">
<Maximize size={14} />
</button>
</Controls>
<MiniMap
className="canvas-minimap"
nodeColor={(n) => {
const d = n.data as Record<string, unknown>;
return (d?.color as string) || '#3b82f6';
}}
maskColor="rgba(0,0,0,0.7)"
/>
</ReactFlow>
</div>
{splitView && (
<>
<div style={{ width: 1, background: '#2a2a32', flexShrink: 0 }} />
<div style={{ flex: 1, position: 'relative', background: '#0e0e10', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#5c5c68' }}>
<SplitSquareHorizontal size={32} style={{ marginBottom: 8, opacity: 0.4 }} />
<p style={{ fontSize: 13 }}>Comparison View</p>
<p style={{ fontSize: 11, marginTop: 4 }}>Select a saved version or branch to compare</p>
</div>
</div>
</>
)}
</div>
{nodes.length === 0 && !splitView && (
<div className="canvas-empty">
<div className="canvas-empty-content">
<div className="canvas-empty-icon"></div>
<h3>Start Building</h3>
<p>Drag components from the left panel onto the canvas to compose your transaction flow</p>
<p className="canvas-empty-hint">or press <kbd>Ctrl+K</kbd> to search components</p>
</div>
</div>
)}
{/* Simulation overlay */}
{isSimulating && (
<div className="simulation-overlay">
<div className="simulation-spinner" />
<span>Running simulation...</span>
</div>
)}
{simulationResults && (
<div className="simulation-results-overlay">
<div className="simulation-results-card">
<div className="simulation-results-header">
<CheckCircle2 size={16} color="#22c55e" />
<span>Simulation Results</span>
<button className="simulation-dismiss" onClick={onDismissSimulation}><X size={14} /></button>
</div>
<pre className="simulation-results-body">{simulationResults}</pre>
</div>
</div>
)}
</div>
<div className="canvas-inspector">
<div className="inspector-item">
<CheckCircle2 size={12} color="#22c55e" />
<span>{nodes.length} nodes</span>
</div>
<div className="inspector-item">
<span>{edges.length} connections</span>
</div>
<div className="inspector-separator" />
<div className="inspector-item">
<AlertTriangle size={12} color={errorCount > 0 ? '#ef4444' : '#555'} />
<span>{errorCount} errors</span>
</div>
<div className="inspector-item">
<AlertTriangle size={12} color={warningCount > 0 ? '#eab308' : '#555'} />
<span>{warningCount} warnings</span>
</div>
<div className="inspector-separator" />
<div className="inspector-item">
<DollarSign size={12} />
<span>Est. fees: {nodes.length > 0 ? '$0.02%' : '—'}</span>
</div>
<div className="inspector-item">
<Clock size={12} />
<span>Settlement: {nodes.length > 0 ? 'T+1' : '—'}</span>
</div>
<div className="inspector-item">
<Globe size={12} />
<span>Jurisdictions: {nodes.length > 0 ? 'Multi' : '—'}</span>
</div>
{selectedNodeIds.size > 0 && (
<>
<div className="inspector-separator" />
<div className="inspector-item selected-info">
<span>{selectedNodeIds.size} selected</span>
</div>
</>
)}
</div>
</div>
);
}
export default function Canvas(props: CanvasProps) {
return (
<ReactFlowProvider>
<CanvasInner {...props} />
</ReactFlowProvider>
);
}

View File

@@ -1,168 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { Search, ArrowRight } from 'lucide-react';
interface Command {
id: string;
label: string;
category: string;
shortcut?: string;
}
const commands: Command[] = [
{ id: 'validate', label: 'Run Validation', category: 'Actions', shortcut: 'Ctrl+Shift+V' },
{ id: 'simulate', label: 'Run Simulation', category: 'Actions', shortcut: 'Ctrl+Shift+S' },
{ id: 'execute', label: 'Execute Transaction', category: 'Actions', shortcut: 'Ctrl+Shift+E' },
{ id: 'toggle-left', label: 'Toggle Left Panel', category: 'View', shortcut: 'Ctrl+B' },
{ id: 'toggle-right', label: 'Toggle Right Panel', category: 'View', shortcut: 'Ctrl+J' },
{ id: 'toggle-bottom', label: 'Toggle Bottom Panel', category: 'View', shortcut: 'Ctrl+`' },
{ id: 'search-components', label: 'Search Components', category: 'Navigation' },
{ id: 'new-transaction', label: 'New Transaction', category: 'File', shortcut: 'Ctrl+N' },
{ id: 'save', label: 'Save Transaction', category: 'File', shortcut: 'Ctrl+S' },
{ id: 'export', label: 'Export Transaction', category: 'File' },
{ id: 'import-template', label: 'Import Template', category: 'File' },
{ id: 'focus-chat', label: 'Focus Chat Panel', category: 'Navigation', shortcut: 'Ctrl+/' },
{ id: 'focus-terminal', label: 'Focus Terminal', category: 'Navigation' },
{ id: 'compliance-pass', label: 'Run Compliance Pass', category: 'Compliance' },
{ id: 'optimize-route', label: 'Optimize Routes', category: 'Routing' },
{ id: 'gen-iso', label: 'Generate ISO-20022 Message', category: 'Messaging' },
{ id: 'audit-export', label: 'Export Audit Summary', category: 'Audit' },
];
interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
onToggleLeft: () => void;
onToggleRight: () => void;
onToggleBottom: () => void;
onValidate: () => void;
onSimulate: () => void;
onExecute: () => void;
onNewTransaction: () => void;
onFocusChat: () => void;
onFocusTerminal: () => void;
onRunCompliance: () => void;
onOptimizeRoute: () => void;
onGenerateISO: () => void;
onExportAudit: () => void;
onSearchComponents: () => void;
}
export default function CommandPalette({
isOpen, onClose,
onToggleLeft, onToggleRight, onToggleBottom,
onValidate, onSimulate, onExecute,
onNewTransaction, onFocusChat, onFocusTerminal,
onRunCompliance, onOptimizeRoute, onGenerateISO, onExportAudit,
onSearchComponents,
}: CommandPaletteProps) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
if (!isOpen) return null;
const filtered = commands.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase()) ||
c.category.toLowerCase().includes(query.toLowerCase())
);
const grouped = filtered.reduce<Record<string, Command[]>>((acc, cmd) => {
if (!acc[cmd.category]) acc[cmd.category] = [];
acc[cmd.category].push(cmd);
return acc;
}, {});
const flatList = Object.values(grouped).flat();
const executeCommand = (id: string) => {
switch (id) {
case 'toggle-left': onToggleLeft(); break;
case 'toggle-right': onToggleRight(); break;
case 'toggle-bottom': onToggleBottom(); break;
case 'validate': onValidate(); break;
case 'simulate': onSimulate(); break;
case 'execute': onExecute(); break;
case 'new-transaction': onNewTransaction(); break;
case 'focus-chat': onFocusChat(); break;
case 'focus-terminal': onFocusTerminal(); break;
case 'compliance-pass': onRunCompliance(); break;
case 'optimize-route': onOptimizeRoute(); break;
case 'gen-iso': onGenerateISO(); break;
case 'audit-export': onExportAudit(); break;
case 'search-components': onSearchComponents(); break;
case 'save': /* already auto-saved */ break;
case 'export': /* export handled */ break;
case 'import-template': /* import handled */ break;
}
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key === 'Enter' && flatList.length > 0) {
executeCommand(flatList[selectedIndex]?.id || flatList[0].id);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, flatList.length - 1));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
}
};
let runningIndex = 0;
return (
<div className="command-palette-overlay" onClick={onClose}>
<div className="command-palette" onClick={e => e.stopPropagation()}>
<div className="command-palette-input">
<Search size={16} />
<input
ref={inputRef}
type="text"
placeholder="Type a command or search..."
value={query}
onChange={e => { setQuery(e.target.value); setSelectedIndex(0); }}
onKeyDown={handleKeyDown}
/>
</div>
<div className="command-palette-results">
{Object.entries(grouped).map(([category, cmds]) => (
<div key={category} className="command-group">
<div className="command-group-header">{category}</div>
{cmds.map(cmd => {
const idx = runningIndex++;
return (
<div
key={cmd.id}
className={`command-item ${idx === selectedIndex ? 'selected' : ''}`}
onClick={() => executeCommand(cmd.id)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<ArrowRight size={12} />
<span className="command-label">{cmd.label}</span>
{cmd.shortcut && <kbd className="command-shortcut">{cmd.shortcut}</kbd>}
</div>
);
})}
</div>
))}
{filtered.length === 0 && (
<div className="command-empty">No commands found</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,334 +0,0 @@
import { useState, type DragEvent } from 'react';
import { Search, Star, Clock, ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
import { componentCategories, componentItems } from '../data/components';
import type { ComponentItem, ActivityTab } from '../types';
interface LeftPanelProps {
width: number;
activityTab: ActivityTab;
recentComponents: string[];
}
const activityTabLabels: Record<ActivityTab, string> = {
builder: 'Components',
assets: 'Assets',
templates: 'Templates',
compliance: 'Compliance',
routes: 'Routes',
protocols: 'Protocols',
agents: 'Agents',
terminal: 'Terminal',
audit: 'Audit',
settings: 'Settings',
};
const activityTabCategories: Partial<Record<ActivityTab, string[]>> = {
assets: ['assets'],
templates: ['templates'],
compliance: ['compliance'],
routes: ['routing'],
protocols: ['messaging'],
};
export default function LeftPanel({ width, activityTab, recentComponents }: LeftPanelProps) {
const [search, setSearch] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(componentCategories.map(c => c.id))
);
const [favorites, setFavorites] = useState<Set<string>>(new Set(['transfer', 'swap', 'kyc']));
const [activeFilter, setActiveFilter] = useState<'all' | 'favorites' | 'recent'>('all');
const [tooltipItem, setTooltipItem] = useState<ComponentItem | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
const toggleCategory = (id: string) => {
const next = new Set(expandedCategories);
if (next.has(id)) next.delete(id); else next.add(id);
setExpandedCategories(next);
};
const toggleFavorite = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
const next = new Set(favorites);
if (next.has(id)) next.delete(id); else next.add(id);
setFavorites(next);
};
const onDragStart = (e: DragEvent, item: ComponentItem) => {
e.dataTransfer.setData('application/transactflow-component', JSON.stringify(item));
e.dataTransfer.effectAllowed = 'move';
// Create drag preview
const preview = document.createElement('div');
preview.className = 'drag-preview';
preview.innerHTML = `<span>${item.icon}</span> <span>${item.label}</span>`;
preview.style.cssText = 'position:fixed;top:-100px;left:-100px;background:#1a1a20;border:1px solid #3b82f6;border-radius:6px;padding:6px 12px;color:#e4e4e8;font-size:12px;display:flex;align-items:center;gap:6px;z-index:10000;pointer-events:none;';
document.body.appendChild(preview);
e.dataTransfer.setDragImage(preview, 0, 0);
setTimeout(() => document.body.removeChild(preview), 0);
};
const showTooltip = (item: ComponentItem, e: React.MouseEvent) => {
setTooltipItem(item);
setTooltipPos({ x: e.clientX + 12, y: e.clientY - 10 });
};
const hideTooltip = () => setTooltipItem(null);
// For non-builder tabs, show filtered content
if (activityTab !== 'builder') {
const categoryFilter = activityTabCategories[activityTab];
if (activityTab === 'settings') {
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header"><span className="panel-title">{activityTabLabels[activityTab]}</span></div>
<div className="left-panel-content">
<div className="settings-panel">
<div className="settings-group">
<div className="settings-group-header">Workspace</div>
<div className="settings-item"><span>Theme</span><span className="settings-value">Dark</span></div>
<div className="settings-item"><span>Font Size</span><span className="settings-value">13px</span></div>
<div className="settings-item"><span>Snap to Grid</span><span className="settings-value">Enabled</span></div>
<div className="settings-item"><span>Grid Size</span><span className="settings-value">16px</span></div>
</div>
<div className="settings-group">
<div className="settings-group-header">Canvas</div>
<div className="settings-item"><span>Auto-save</span><span className="settings-value">On</span></div>
<div className="settings-item"><span>Minimap</span><span className="settings-value">Visible</span></div>
<div className="settings-item"><span>Animations</span><span className="settings-value">Enabled</span></div>
</div>
<div className="settings-group">
<div className="settings-group-header">Compliance</div>
<div className="settings-item"><span>Auto-validate</span><span className="settings-value">Off</span></div>
<div className="settings-item"><span>Jurisdiction</span><span className="settings-value">Multi</span></div>
</div>
</div>
</div>
</div>
);
}
if (activityTab === 'terminal' || activityTab === 'audit') {
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header"><span className="panel-title">{activityTabLabels[activityTab]}</span></div>
<div className="left-panel-content">
<div className="empty-state">
<p>{activityTab === 'terminal' ? 'Terminal output is shown in the bottom panel.' : 'Audit trail is shown in the bottom panel.'}</p>
<p style={{ marginTop: 8, fontSize: 11 }}>Use <kbd style={{ background: '#1a1a20', border: '1px solid #2a2a32', borderRadius: 3, padding: '1px 4px', fontSize: 10 }}>Ctrl+`</kbd> to toggle the bottom panel.</p>
</div>
</div>
</div>
);
}
if (activityTab === 'agents') {
const agentList = [
{ name: 'Builder Agent', desc: 'Helps construct transaction flows', color: '#3b82f6' },
{ name: 'Compliance Agent', desc: 'Monitors policy violations', color: '#22c55e' },
{ name: 'Routing Agent', desc: 'Optimizes execution paths', color: '#f97316' },
{ name: 'ISO-20022 Agent', desc: 'Generates messaging payloads', color: '#a855f7' },
{ name: 'Settlement Agent', desc: 'Manages settlement instructions', color: '#eab308' },
{ name: 'Risk Agent', desc: 'Evaluates transaction risk', color: '#ef4444' },
{ name: 'Documentation Agent', desc: 'Generates deal memos', color: '#6b7280' },
];
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header"><span className="panel-title">Agents</span></div>
<div className="left-panel-content">
{agentList.map(a => (
<div key={a.name} className="agent-list-item">
<div className="agent-list-dot" style={{ background: a.color }} />
<div>
<div className="agent-list-name">{a.name}</div>
<div className="agent-list-desc">{a.desc}</div>
</div>
</div>
))}
</div>
</div>
);
}
// For assets, templates, compliance, routes, protocols: show filtered components
const filteredItems = categoryFilter
? componentItems.filter(i => categoryFilter.includes(i.category))
: componentItems;
const searchFiltered = filteredItems.filter(item =>
item.label.toLowerCase().includes(search.toLowerCase()) ||
item.description.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header"><span className="panel-title">{activityTabLabels[activityTab]}</span></div>
<div className="left-panel-search">
<Search size={14} className="search-icon" />
<input
type="text"
placeholder={`Search ${activityTabLabels[activityTab].toLowerCase()}...`}
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="left-panel-content">
<div className="category-items flat">
{searchFiltered.map(item => (
<div
key={item.id}
className="component-item"
draggable
onDragStart={e => onDragStart(e, item)}
onMouseEnter={e => showTooltip(item, e)}
onMouseLeave={hideTooltip}
>
<GripVertical size={12} className="drag-handle" />
<span className="component-icon">{item.icon}</span>
<span className="component-label">{item.label}</span>
<button className={`fav-btn ${favorites.has(item.id) ? 'active' : ''}`} onClick={e => toggleFavorite(item.id, e)}>
<Star size={11} />
</button>
</div>
))}
{searchFiltered.length === 0 && <div className="empty-state">No items found</div>}
</div>
</div>
{tooltipItem && (
<div className="component-tooltip" style={{ top: tooltipPos.y, left: tooltipPos.x }}>
<div className="tooltip-header">{tooltipItem.icon} {tooltipItem.label}</div>
<div className="tooltip-desc">{tooltipItem.description}</div>
<div className="tooltip-meta">
<span className="tooltip-cat" style={{ color: tooltipItem.color }}>
{componentCategories.find(c => c.id === tooltipItem.category)?.label}
</span>
</div>
{tooltipItem.inputs && <div className="tooltip-fields"><strong>Inputs:</strong> {tooltipItem.inputs.join(', ')}</div>}
{tooltipItem.outputs && <div className="tooltip-fields"><strong>Outputs:</strong> {tooltipItem.outputs.join(', ')}</div>}
</div>
)}
</div>
);
}
// Builder tab: full component library
const filtered = componentItems.filter(item =>
item.label.toLowerCase().includes(search.toLowerCase()) ||
item.description.toLowerCase().includes(search.toLowerCase())
);
const displayItems = activeFilter === 'favorites'
? filtered.filter(i => favorites.has(i.id))
: activeFilter === 'recent'
? filtered.filter(i => recentComponents.includes(i.id)).sort((a, b) => recentComponents.indexOf(a.id) - recentComponents.indexOf(b.id))
: filtered;
return (
<div className="left-panel" style={{ width }}>
<div className="panel-header">
<span className="panel-title">Components</span>
</div>
<div className="left-panel-search">
<Search size={14} className="search-icon" />
<input
type="text"
placeholder="Search components..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="left-panel-filters">
<button className={`filter-btn ${activeFilter === 'all' ? 'active' : ''}`} onClick={() => setActiveFilter('all')}>All</button>
<button className={`filter-btn ${activeFilter === 'favorites' ? 'active' : ''}`} onClick={() => setActiveFilter('favorites')}>
<Star size={11} /> Favorites
</button>
<button className={`filter-btn ${activeFilter === 'recent' ? 'active' : ''}`} onClick={() => setActiveFilter('recent')}>
<Clock size={11} /> Recent
</button>
</div>
<div className="left-panel-content">
{activeFilter === 'all' && !search ? (
componentCategories.map(cat => {
const catItems = displayItems.filter(i => i.category === cat.id);
if (catItems.length === 0) return null;
const isExpanded = expandedCategories.has(cat.id);
return (
<div key={cat.id} className="component-category">
<div className="category-header" onClick={() => toggleCategory(cat.id)}>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="category-icon">{cat.icon}</span>
<span className="category-label">{cat.label}</span>
<span className="category-count">{catItems.length}</span>
</div>
{isExpanded && (
<div className="category-items">
{catItems.map(item => (
<div
key={item.id}
className="component-item"
draggable
onDragStart={e => onDragStart(e, item)}
onMouseEnter={e => showTooltip(item, e)}
onMouseLeave={hideTooltip}
>
<GripVertical size={12} className="drag-handle" />
<span className="component-icon">{item.icon}</span>
<span className="component-label">{item.label}</span>
<button className={`fav-btn ${favorites.has(item.id) ? 'active' : ''}`} onClick={e => toggleFavorite(item.id, e)}>
<Star size={11} />
</button>
</div>
))}
</div>
)}
</div>
);
})
) : (
<div className="category-items flat">
{displayItems.map(item => (
<div
key={item.id}
className="component-item"
draggable
onDragStart={e => onDragStart(e, item)}
onMouseEnter={e => showTooltip(item, e)}
onMouseLeave={hideTooltip}
>
<GripVertical size={12} className="drag-handle" />
<span className="component-icon">{item.icon}</span>
<span className="component-label">{item.label}</span>
<span className="component-category-badge">
{componentCategories.find(c => c.id === item.category)?.label}
</span>
<button className={`fav-btn ${favorites.has(item.id) ? 'active' : ''}`} onClick={e => toggleFavorite(item.id, e)}>
<Star size={11} />
</button>
</div>
))}
{displayItems.length === 0 && (
<div className="empty-state">
{activeFilter === 'recent' ? 'No recently used components. Drag a component to the canvas to see it here.' : 'No matching components found.'}
</div>
)}
</div>
)}
</div>
{tooltipItem && (
<div className="component-tooltip" style={{ top: tooltipPos.y, left: tooltipPos.x }}>
<div className="tooltip-header">{tooltipItem.icon} {tooltipItem.label}</div>
<div className="tooltip-desc">{tooltipItem.description}</div>
<div className="tooltip-meta">
<span className="tooltip-cat" style={{ color: tooltipItem.color }}>
{componentCategories.find(c => c.id === tooltipItem.category)?.label}
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,370 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import {
Send, Sparkles, Wrench, ShieldCheck, Route, FileText,
Landmark, AlertTriangle, BookOpen, ChevronDown, Plus,
Zap, RefreshCw, FileOutput, MessageSquare,
History, Target
} from 'lucide-react';
import type { Agent, ChatMessage, ConversationScope, ComponentItem } from '../types';
import { sampleMessages, sampleThreads } from '../data/sampleData';
import { componentItems } from '../data/components';
import type { Node, Edge } from '@xyflow/react';
const agents: { id: Agent; icon: typeof Sparkles; color: string }[] = [
{ id: 'Builder', icon: Sparkles, color: '#3b82f6' },
{ id: 'Compliance', icon: ShieldCheck, color: '#22c55e' },
{ id: 'Routing', icon: Route, color: '#f97316' },
{ id: 'ISO-20022', icon: FileText, color: '#a855f7' },
{ id: 'Settlement', icon: Landmark, color: '#eab308' },
{ id: 'Risk', icon: AlertTriangle, color: '#ef4444' },
{ id: 'Documentation', icon: BookOpen, color: '#6b7280' },
];
interface RightPanelProps {
width: number;
nodes: Node[];
edges: Edge[];
selectedNodes: Node[];
chatInputRef: React.RefObject<HTMLInputElement | null>;
onInsertBlock: (item: ComponentItem, position: { x: number; y: number }) => void;
onRunValidation: () => void;
onOptimizeRoute: () => void;
onRunCompliance: () => void;
onGenerateSettlement: () => void;
}
export default function RightPanel({
width, nodes, edges, selectedNodes, chatInputRef,
onInsertBlock, onRunValidation, onOptimizeRoute, onRunCompliance, onGenerateSettlement,
}: RightPanelProps) {
const [activeAgent, setActiveAgent] = useState<Agent>('Builder');
const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);
const [input, setInput] = useState('');
const [showAgentMenu, setShowAgentMenu] = useState(false);
const [showContext, setShowContext] = useState(false);
const [showThreads, setShowThreads] = useState(false);
const [scope, setScope] = useState<ConversationScope>('full-transaction');
const [showScopeMenu, setShowScopeMenu] = useState(false);
const messagesEnd = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const getCanvasContext = () => {
const nodeLabels = nodes.map(n => (n.data as Record<string, unknown>).label as string);
const categories = [...new Set(nodes.map(n => (n.data as Record<string, unknown>).category as string))];
const hasCompliance = categories.includes('compliance');
const hasRouting = categories.includes('routing');
const disconnected = nodes.filter(n => !edges.some(e => e.source === n.id || e.target === n.id));
const selectedLabels = selectedNodes.map(n => (n.data as Record<string, unknown>).label as string);
return { nodeLabels, categories, hasCompliance, hasRouting, disconnected, selectedLabels, nodeCount: nodes.length, edgeCount: edges.length };
};
const sendMessage = () => {
if (!input.trim()) return;
const userMsg: ChatMessage = {
id: Date.now().toString(),
agent: 'User',
content: input,
timestamp: new Date(),
type: 'user',
};
setMessages(prev => [...prev, userMsg]);
const capturedInput = input;
setInput('');
setTimeout(() => {
const ctx = getCanvasContext();
const buildResponse = (): string => {
const lowerInput = capturedInput.toLowerCase();
switch (activeAgent) {
case 'Builder': {
if (selectedNodes.length > 0) {
const sel = (selectedNodes[0].data as Record<string, unknown>).label as string;
if (lowerInput.includes('explain')) return `The "${sel}" block ${getBlockExplanation(sel)}. It currently has ${edges.filter(e => e.source === selectedNodes[0].id || e.target === selectedNodes[0].id).length} connection(s).`;
if (lowerInput.includes('next') || lowerInput.includes('suggest')) return `After "${sel}", I recommend adding a ${suggestNextBlock(sel)}. This would complete the ${(selectedNodes[0].data as Record<string, unknown>).category} flow.`;
}
if (lowerInput.includes('build') || lowerInput.includes('create') || lowerInput.includes('set up') || lowerInput.includes('payment')) {
if (ctx.nodeCount === 0) return `To build a transaction flow, start by dragging a "Fiat Account" or "Stablecoin Wallet" from the left panel as your source. Then add a "${capturedInput.includes('swap') ? 'Swap' : 'Transfer'}" action and connect them.`;
return `Your graph has ${ctx.nodeCount} nodes. Try dragging a "${capturedInput.includes('swap') ? 'Swap' : 'Transfer'}" block onto the canvas and connecting it to your source.`;
}
if (ctx.disconnected.length > 0) return `I notice ${ctx.disconnected.length} disconnected node(s) in your graph: ${ctx.disconnected.map(n => (n.data as Record<string, unknown>).label).join(', ')}. Connect them to complete the flow.`;
return `I can help you build that flow. Try dragging a "${capturedInput.includes('swap') ? 'Swap' : 'Transfer'}" block onto the canvas and connecting it to your source. Your graph currently has ${ctx.nodeCount} nodes and ${ctx.edgeCount} connections.`;
}
case 'Compliance': {
if (lowerInput.includes('check') || lowerInput.includes('compliance') || lowerInput.includes('review')) {
if (!ctx.hasCompliance && ctx.nodeCount > 0) return `WARNING: Your transaction graph has ${ctx.nodeCount} nodes but no compliance checks. I recommend adding KYC and AML nodes before the settlement step. This is required for cross-border transactions.`;
if (ctx.hasCompliance) return `Running compliance check on the current graph. No policy violations detected for the selected jurisdiction. ${ctx.nodeCount} nodes verified against 47 compliance rules.`;
return `Running compliance check on the current graph. No policy violations detected for the selected jurisdiction.`;
}
if (lowerInput.includes('violation') || lowerInput.includes('failure')) return `Scanning graph for policy violations... ${ctx.hasCompliance ? 'All compliance nodes are properly configured. No violations found.' : 'No compliance nodes found in graph. Consider adding KYC/AML checks.'}`;
return `Running compliance check on the current graph. No policy violations detected for the selected jurisdiction.`;
}
case 'Routing': {
if (ctx.hasRouting) return `Analyzing ${ctx.nodeCount} nodes with routing configuration. Found optimal path via ${ctx.nodeLabels.find(l => l.includes('Route') || l.includes('Router')) || 'Banking Rail'}. Estimated fee: 0.02%, latency: 230ms.`;
return `Analyzing optimal routes... Found 3 execution paths. The best route via Banking Rail offers lowest fees at 0.02%. Your graph has ${ctx.nodeCount} nodes across ${ctx.edgeCount} connections.`;
}
case 'ISO-20022': {
if (ctx.nodeCount > 0) return `Based on your graph with ${ctx.nodeCount} nodes, I can generate a pain.001 message. The required fields from your current configuration: debtor (${ctx.nodeLabels[0] || 'source'}), creditor (${ctx.nodeLabels[ctx.nodeLabels.length - 1] || 'destination'}), amount, and currency.`;
return `I can generate a pain.001 message for this transfer. The required fields based on your current graph are: debtor, creditor, amount, and currency.`;
}
case 'Settlement': {
return `Current settlement window for this transaction type is T+1. ${ctx.nodeCount > 0 ? `Your graph has ${ctx.nodeCount} nodes ready for settlement processing.` : 'I recommend adding a settlement instruction block to specify your preferred CSD.'}`;
}
case 'Risk': {
if (ctx.nodeCount > 0) {
const riskLevel = ctx.hasCompliance ? 'LOW' : 'MEDIUM';
return `Risk assessment: ${riskLevel}. ${ctx.nodeCount} nodes evaluated. ${ctx.hasCompliance ? 'Compliance checks present.' : 'No compliance nodes — risk elevated.'} ${ctx.disconnected.length > 0 ? `${ctx.disconnected.length} disconnected node(s) detected.` : 'All nodes connected.'}`;
}
return `Risk assessment: LOW. Transaction amount is within normal parameters. No counterparty risk flags detected.`;
}
case 'Documentation': {
if (ctx.nodeCount > 0) return `Generating deal memo for "${ctx.nodeLabels[0]}" flow with ${ctx.nodeCount} nodes. Categories: ${ctx.categories.join(', ')}. ${ctx.edgeCount} connections mapped. ${ctx.hasCompliance ? 'Compliance: verified.' : 'Compliance: not yet added.'}`;
return `I'll generate a deal memo for this transaction. It will include the execution path, compliance checks, and settlement instructions.`;
}
default:
return 'How can I assist you?';
}
};
const reply: ChatMessage = {
id: (Date.now() + 1).toString(),
agent: activeAgent,
content: buildResponse(),
timestamp: new Date(),
type: 'agent',
};
setMessages(prev => [...prev, reply]);
}, 800);
};
const currentAgentDef = agents.find(a => a.id === activeAgent)!;
const CurrentIcon = currentAgentDef.icon;
const scopeLabels: Record<ConversationScope, string> = {
'current-node': 'Current Node',
'current-flow': 'Current Flow',
'full-transaction': 'Full Transaction',
'terminal': 'Terminal',
'compliance': 'Compliance Only',
};
// Context from canvas
const ctx = getCanvasContext();
const handleInsertBlock = () => {
const suggestions = ['transfer', 'kyc', 'banking-rail'];
const item = componentItems.find(c => c.id === suggestions[Math.floor(Math.random() * suggestions.length)]);
if (item) onInsertBlock(item, { x: 250 + Math.random() * 200, y: 150 + Math.random() * 200 });
};
return (
<div className="right-panel" style={{ width }}>
<div className="panel-header">
<div className="chat-header-agent" onClick={() => setShowAgentMenu(!showAgentMenu)}>
<CurrentIcon size={14} color={currentAgentDef.color} />
<span>{activeAgent} Agent</span>
<ChevronDown size={12} />
{showAgentMenu && (
<div className="agent-dropdown">
{agents.map(a => {
const Icon = a.icon;
return (
<div
key={a.id}
className={`agent-option ${a.id === activeAgent ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); setActiveAgent(a.id); setShowAgentMenu(false); }}
>
<Icon size={14} color={a.color} />
<span>{a.id}</span>
</div>
);
})}
</div>
)}
</div>
<div className="chat-header-actions">
<div className="scope-selector" onClick={() => setShowScopeMenu(!showScopeMenu)}>
<Target size={11} />
<span>{scopeLabels[scope]}</span>
<ChevronDown size={10} />
{showScopeMenu && (
<div className="scope-dropdown">
{(Object.keys(scopeLabels) as ConversationScope[]).map(s => (
<div
key={s}
className={`scope-option ${s === scope ? 'active' : ''}`}
onClick={e => { e.stopPropagation(); setScope(s); setShowScopeMenu(false); }}
>
{scopeLabels[s]}
</div>
))}
</div>
)}
</div>
<button
className={`icon-btn-xs ${showThreads ? 'active' : ''}`}
onClick={() => setShowThreads(!showThreads)}
title="Thread History"
>
<History size={12} />
</button>
<button
className={`context-toggle ${showContext ? 'active' : ''}`}
onClick={() => setShowContext(!showContext)}
title="Toggle context panel"
>
Context
</button>
</div>
</div>
<div className="agent-tabs">
{agents.map(a => {
const Icon = a.icon;
return (
<button
key={a.id}
className={`agent-tab ${a.id === activeAgent ? 'active' : ''}`}
onClick={() => setActiveAgent(a.id)}
title={a.id}
style={a.id === activeAgent ? { borderBottomColor: a.color } : {}}
>
<Icon size={13} color={a.id === activeAgent ? a.color : '#666'} />
</button>
);
})}
</div>
{showThreads && (
<div className="thread-history">
<div className="thread-history-header">Thread History</div>
{sampleThreads.map(t => (
<div key={t.id} className="thread-item" onClick={() => setShowThreads(false)}>
<MessageSquare size={12} color={agents.find(a => a.id === t.agent)?.color} />
<div className="thread-item-content">
<span className="thread-item-title">{t.title}</span>
<span className="thread-item-meta">{t.agent} · {t.messageCount} messages</span>
</div>
</div>
))}
</div>
)}
{showContext && (
<div className="context-panel">
<div className="context-section">
<span className="context-label">Selected</span>
<span className="context-value">{ctx.selectedLabels.length > 0 ? ctx.selectedLabels.join(', ') : 'None'}</span>
</div>
<div className="context-section">
<span className="context-label">Nodes</span>
<span className="context-value">{ctx.nodeCount}</span>
</div>
<div className="context-section">
<span className="context-label">Connections</span>
<span className="context-value">{ctx.edgeCount}</span>
</div>
<div className="context-section">
<span className="context-label">Jurisdiction</span>
<span className="context-value">Multi</span>
</div>
<div className="context-section">
<span className="context-label">Counterparties</span>
<span className="context-value">{ctx.nodeCount > 0 ? Math.max(1, Math.floor(ctx.nodeCount / 3)) : 0}</span>
</div>
<div className="context-section">
<span className="context-label">Compliance</span>
<span className={`context-value ${ctx.hasCompliance ? 'pass' : (ctx.nodeCount > 0 ? 'warn' : '')}`}>
{ctx.hasCompliance ? 'Pass' : (ctx.nodeCount > 0 ? 'Missing' : 'N/A')}
</span>
</div>
<div className="context-section">
<span className="context-label">Categories</span>
<span className="context-value">{ctx.categories.length > 0 ? ctx.categories.join(', ') : '—'}</span>
</div>
<div className="context-section">
<span className="context-label">Est. Fees</span>
<span className="context-value">{ctx.nodeCount > 0 ? '$0.02%' : '—'}</span>
</div>
</div>
)}
<div className="chat-messages">
{messages.map(msg => (
<div key={msg.id} className={`chat-message ${msg.type}`}>
<div className="message-header">
<span className="message-agent">{msg.agent}</span>
<span className="message-time">
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="message-content">{msg.content}</div>
</div>
))}
<div ref={messagesEnd} />
</div>
<div className="action-tray">
<button className="action-tray-btn" title="Insert recommended block" onClick={handleInsertBlock}>
<Plus size={12} /> Insert Block
</button>
<button className="action-tray-btn" title="Repair graph" onClick={onRunValidation}>
<Wrench size={12} /> Repair
</button>
<button className="action-tray-btn" title="Optimize route" onClick={onOptimizeRoute}>
<Zap size={12} /> Optimize
</button>
<button className="action-tray-btn" title="Run compliance" onClick={onRunCompliance}>
<ShieldCheck size={12} /> Comply
</button>
<button className="action-tray-btn" title="Generate settlement message" onClick={onGenerateSettlement}>
<FileOutput size={12} /> Settle
</button>
<button className="action-tray-btn" title="Refresh context" onClick={() => setShowContext(true)}>
<RefreshCw size={12} />
</button>
</div>
<div className="chat-input-area">
<input
ref={chatInputRef}
type="text"
placeholder={`Ask ${activeAgent} Agent...`}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
/>
<button className="send-btn" onClick={sendMessage} disabled={!input.trim()}>
<Send size={14} />
</button>
</div>
</div>
);
}
function getBlockExplanation(label: string): string {
const explanations: Record<string, string> = {
'Fiat Account': 'represents a traditional fiat currency account, typically used as a source or destination for fund transfers',
'Transfer': 'moves value from one account to another along a defined path',
'KYC': 'performs Know Your Customer verification before allowing the transaction to proceed',
'AML': 'runs Anti-Money Laundering screening against watchlists',
'Swap': 'exchanges one asset type for another at the current market rate',
'Banking Rail': 'routes the transaction through traditional banking infrastructure',
};
return explanations[label] || 'is a transaction primitive used in flow composition';
}
function suggestNextBlock(label: string): string {
const suggestions: Record<string, string> = {
'Fiat Account': 'Transfer or Convert block',
'Transfer': 'KYC compliance check',
'KYC': 'AML screening node',
'AML': 'Banking Rail or DEX Route',
'Swap': 'Settlement instruction',
'Banking Rail': 'Settlement instruction',
};
return suggestions[label] || 'compliance or routing node';
}

View File

@@ -1,166 +0,0 @@
import { useState } from 'react';
import {
Search, Bell, ChevronDown, Play, FlaskConical, ShieldCheck, Zap,
Command, User, LogOut, Settings, Shield
} from 'lucide-react';
import type { SessionMode } from '../types';
import { sampleNotifications } from '../data/sampleData';
const modeColors: Record<SessionMode, string> = {
Sandbox: '#eab308',
Simulate: '#3b82f6',
Live: '#22c55e',
'Compliance Review': '#a855f7',
};
interface TitleBarProps {
mode: SessionMode;
onModeChange: (mode: SessionMode) => void;
onToggleCommandPalette: () => void;
onValidate: () => void;
onSimulate: () => void;
onExecute: () => void;
}
export default function TitleBar({ mode, onModeChange, onToggleCommandPalette, onValidate, onSimulate, onExecute }: TitleBarProps) {
const [showModeMenu, setShowModeMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [notifications, setNotifications] = useState(sampleNotifications);
const modes: SessionMode[] = ['Sandbox', 'Simulate', 'Live', 'Compliance Review'];
const unreadCount = notifications.filter(n => !n.read).length;
const markAllRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
};
const notifTypeColors: Record<string, string> = {
info: '#3b82f6',
success: '#22c55e',
warning: '#eab308',
error: '#ef4444',
};
return (
<div className="title-bar">
<div className="title-bar-left">
<div className="title-bar-logo">
<Zap size={18} color="#3b82f6" />
<span className="title-bar-name">TransactFlow</span>
</div>
<div className="title-bar-separator" />
<span className="title-bar-workspace">Institutional Workspace</span>
<div className="title-bar-separator" />
<div className="mode-selector" onClick={() => setShowModeMenu(!showModeMenu)}>
<div className="mode-dot" style={{ background: modeColors[mode] }} />
<span>{mode}</span>
<ChevronDown size={12} />
{showModeMenu && (
<div className="mode-dropdown">
{modes.map(m => (
<div
key={m}
className={`mode-option ${m === mode ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); onModeChange(m); setShowModeMenu(false); }}
>
<div className="mode-dot" style={{ background: modeColors[m] }} />
{m}
</div>
))}
</div>
)}
</div>
</div>
<div className="title-bar-center">
<button className="title-bar-search" onClick={onToggleCommandPalette}>
<Search size={13} />
<span>Search or run command...</span>
<kbd>Ctrl+K</kbd>
</button>
</div>
<div className="title-bar-right">
<button className="title-bar-action validate" title="Validate (Ctrl+Shift+V)" onClick={onValidate}>
<ShieldCheck size={15} />
<span>Validate</span>
</button>
<button className="title-bar-action simulate" title="Simulate (Ctrl+Shift+S)" onClick={onSimulate}>
<FlaskConical size={15} />
<span>Simulate</span>
</button>
<button className="title-bar-action execute" title="Execute (Ctrl+Shift+E)" onClick={onExecute}>
<Play size={15} />
<span>Execute</span>
</button>
<div className="title-bar-separator" />
{/* Notification bell with dropdown */}
<div className="notification-wrapper">
<button className="icon-btn" title="Notifications" onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}>
<Bell size={16} />
{unreadCount > 0 && <span className="notification-badge">{unreadCount}</span>}
</button>
{showNotifications && (
<div className="notification-dropdown">
<div className="notification-dropdown-header">
<span>Notifications</span>
{unreadCount > 0 && (
<button className="mark-read-btn" onClick={markAllRead}>Mark all read</button>
)}
</div>
{notifications.map(n => (
<div key={n.id} className={`notification-item ${n.read ? 'read' : ''}`} onClick={() => setNotifications(prev => prev.map(x => x.id === n.id ? { ...x, read: true } : x))}>
<div className="notification-dot" style={{ background: notifTypeColors[n.type] }} />
<div className="notification-content">
<span className="notification-title">{n.title}</span>
<span className="notification-message">{n.message}</span>
<span className="notification-time">{n.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
))}
</div>
)}
</div>
<button className="icon-btn" title="Command Palette" onClick={onToggleCommandPalette}>
<Command size={16} />
</button>
{/* User menu with dropdown */}
<div className="user-menu-wrapper">
<button className="icon-btn user-btn" title="User Settings" onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}>
<User size={16} />
</button>
{showUserMenu && (
<div className="user-menu-dropdown">
<div className="user-menu-profile">
<div className="user-avatar">JD</div>
<div>
<div className="user-name">Jane Doe</div>
<div className="user-role">Admin · Compliance Officer</div>
</div>
</div>
<div className="user-menu-divider" />
<div className="user-menu-item">
<User size={13} /> <span>Profile</span>
</div>
<div className="user-menu-item">
<Settings size={13} /> <span>Settings</span>
</div>
<div className="user-menu-item">
<Shield size={13} /> <span>Permissions</span>
<span className="user-menu-badge">Admin</span>
</div>
<div className="user-menu-divider" />
<div className="user-menu-item logout">
<LogOut size={13} /> <span>Sign Out</span>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,54 +0,0 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { AlertTriangle, CheckCircle2, XCircle, Shield } from 'lucide-react';
type TransactionNodeData = {
label: string;
category: string;
icon: string;
color: string;
status?: 'valid' | 'warning' | 'error';
};
const complianceCategories = ['compliance'];
const routingCategories = ['routing'];
function TransactionNodeComponent({ data, selected }: NodeProps) {
const nodeData = data as unknown as TransactionNodeData;
const statusIcon = nodeData.status === 'valid' ? <CheckCircle2 size={10} color="#22c55e" /> :
nodeData.status === 'warning' ? <AlertTriangle size={10} color="#eab308" /> :
nodeData.status === 'error' ? <XCircle size={10} color="#ef4444" /> : null;
const isCompliance = complianceCategories.includes(nodeData.category);
const isRouting = routingCategories.includes(nodeData.category);
return (
<div className={`transaction-node ${selected ? 'selected' : ''} ${nodeData.status ? `status-${nodeData.status}` : ''}`}
style={{ borderColor: selected ? '#3b82f6' : nodeData.color + '60' }}>
<Handle type="target" position={Position.Left} className="node-handle" />
<div className="node-header" style={{ borderBottomColor: nodeData.color + '30' }}>
<span className="node-icon">{nodeData.icon}</span>
<span className="node-label">{nodeData.label}</span>
<div className="node-badges">
{isCompliance && (
<span className="node-badge compliance" title="Compliance node">
<Shield size={8} />
</span>
)}
{isRouting && (
<span className="node-badge routing" title="Routing node">
🔀
</span>
)}
{statusIcon && <span className="node-status">{statusIcon}</span>}
</div>
</div>
<div className="node-body">
<span className="node-category" style={{ color: nodeData.color }}>{nodeData.category}</span>
</div>
<Handle type="source" position={Position.Right} className="node-handle" />
</div>
);
}
export default memo(TransactionNodeComponent);

View File

@@ -1,16 +0,0 @@
/**
* SkipToContent — first focusable element in the app.
* Keyboard users press Tab on page load; this link appears and, on
* Enter, jumps focus past the nav to the <main id="main-content">.
*
* Render once, near the top of the tree. The target element must
* exist with id="main-content" and tabIndex={-1} so focus can land
* on it programmatically.
*/
export default function SkipToContent({ targetId = 'main-content' }: { targetId?: string }) {
return (
<a className="skip-to-content" href={`#${targetId}`}>
Skip to main content
</a>
);
}

View File

@@ -1,11 +0,0 @@
import type { PropsWithChildren } from 'react';
/**
* VisuallyHidden — renders content that is present in the DOM (and
* announced by screen readers) but visually hidden. Prefer this over
* aria-label when the label would benefit from being inspectable in
* devtools.
*/
export default function VisuallyHidden({ children }: PropsWithChildren) {
return <span className="sr-only">{children}</span>;
}

View File

@@ -1,52 +0,0 @@
import { useEffect, useState } from 'react';
import { Circle, AlertCircle, CheckCircle2, MinusCircle } from 'lucide-react';
import { backendCatalog, type BackendDescriptor, type BackendStatus } from '../../config/endpoints';
import { getChainHealth } from '../../services/chain138';
import { getExplorerStats } from '../../services/explorer';
const STATUS_STYLE: Record<BackendStatus, { color: string; label: string; Icon: typeof CheckCircle2 }> = {
live: { color: '#22c55e', label: 'Live', Icon: CheckCircle2 },
'bff-required': { color: '#eab308', label: 'BFF required', Icon: AlertCircle },
mocked: { color: '#6b7280', label: 'Mocked', Icon: MinusCircle },
degraded: { color: '#ef4444', label: 'Degraded', Icon: AlertCircle },
};
export default function BackendStatusBar() {
const [probed, setProbed] = useState<Record<string, BackendStatus>>({});
useEffect(() => {
let cancelled = false;
(async () => {
const results = await Promise.allSettled([
getChainHealth().then(() => 'live' as const),
getExplorerStats().then(() => 'live' as const),
]);
if (cancelled) return;
setProbed({
chain138: results[0].status === 'fulfilled' ? 'live' : 'degraded',
explorer: results[1].status === 'fulfilled' ? 'live' : 'degraded',
});
})();
return () => { cancelled = true; };
}, []);
const withProbed = (b: BackendDescriptor): BackendDescriptor => ({
...b,
status: probed[b.id] ?? b.status,
});
return (
<div className="backend-status-bar" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', padding: '6px 12px', background: 'rgba(17,24,39,0.6)', borderRadius: 8, border: '1px solid rgba(75,85,99,0.3)', alignItems: 'center', fontSize: 11 }}>
<Circle size={8} style={{ color: '#9ca3af', fill: '#9ca3af' }} />
<span style={{ color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>Backends</span>
{backendCatalog.map(withProbed).map(b => {
const s = STATUS_STYLE[b.status];
return (
<span key={b.id} title={`${b.name}${b.note}\n${b.url}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, background: `${s.color}18`, color: s.color, cursor: 'help' }}>
<s.Icon size={11} /> {b.name} <span style={{ opacity: 0.7 }}>· {s.label}</span>
</span>
);
})}
</div>
);
}

View File

@@ -1,50 +0,0 @@
import { useLiveChain } from '../../hooks/useLiveChain';
import { endpoints } from '../../config/endpoints';
import { Zap } from 'lucide-react';
/**
* Slim one-line banner suitable for embedding above dense UIs (e.g. the
* transaction-builder canvas). Shows chain health + block + gas.
* Flips to a red "RPC degraded" state on polling failure so you don't
* accidentally compose a tx against a dead endpoint.
*/
export default function LiveChainBanner() {
const { health, error, lastUpdated } = useLiveChain();
const ok = !error && !!health;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
padding: '6px 12px',
fontSize: 11,
background: ok ? 'rgba(34,197,94,0.06)' : error ? 'rgba(239,68,68,0.08)' : 'rgba(148,163,184,0.06)',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
>
<Zap size={12} style={{ color: ok ? '#22c55e' : error ? '#ef4444' : '#6b7280' }} />
<span style={{ color: '#cbd5e1' }}>
Chain {endpoints.chain138.chainId} ({endpoints.chain138.name})
</span>
<span style={{ color: ok ? '#22c55e' : error ? '#ef4444' : '#eab308' }}>
{error ? `● RPC degraded · ${error}` : ok ? '● LIVE' : '○ connecting…'}
</span>
{ok && (
<>
<span className="mono" style={{ color: '#6b7280' }}>block</span>
<span className="mono">{health.blockNumber.toLocaleString()}</span>
<span className="mono" style={{ color: '#6b7280' }}>gas</span>
<span className="mono">{health.gasPriceGwei.toFixed(4)} gwei</span>
<span className="mono" style={{ color: '#6b7280' }}>rpc</span>
<span className="mono">{health.latencyMs}ms</span>
</>
)}
<span style={{ marginLeft: 'auto', color: '#6b7280' }}>
rpc: <a href={endpoints.chain138.rpcUrl} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>{endpoints.chain138.rpcUrl}</a>
{lastUpdated && <span style={{ marginLeft: 8 }}>· {lastUpdated.toLocaleTimeString()}</span>}
</span>
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { Activity, Box, Cpu, ExternalLink, Gauge, Radio, RefreshCw } from 'lucide-react';
import { useLiveChain } from '../../hooks/useLiveChain';
import { endpoints } from '../../config/endpoints';
import { explorerBlockUrl } from '../../services/explorer';
function ago(date: Date | null): string {
if (!date) return '—';
const s = Math.round((Date.now() - date.getTime()) / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
return `${Math.floor(m / 60)}h ago`;
}
export default function LiveNetworkPanel() {
const { health, latestBlock, stats, loading, error, lastUpdated, refresh } = useLiveChain();
const chainOk = health && health.chainId === endpoints.chain138.chainId;
const statusLabel = error ? 'degraded' : chainOk ? 'live' : loading ? 'connecting' : 'offline';
const statusColor = error ? '#ef4444' : chainOk ? '#22c55e' : loading ? '#eab308' : '#6b7280';
return (
<div className="dashboard-card live-network-panel">
<div className="card-header">
<h3>
<Radio size={16} style={{ color: statusColor }} /> Chain 138 Live Network
<span className="status-pill" style={{ background: `${statusColor}22`, color: statusColor, marginLeft: 8, padding: '2px 8px', borderRadius: 10, fontSize: 10, textTransform: 'uppercase' }}>
{statusLabel}
</span>
</h3>
<div className="card-header-actions" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#9ca3af' }}>{ago(lastUpdated)}</span>
<button className="card-action" onClick={refresh} title="Refresh">
<RefreshCw size={12} />
</button>
</div>
</div>
<div className="live-network-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, padding: '12px 16px' }}>
<Stat icon={<Box size={14} />} label="Latest block" mono value={health ? health.blockNumber.toLocaleString() : '—'} href={latestBlock ? explorerBlockUrl(latestBlock.number) : undefined} />
<Stat icon={<Cpu size={14} />} label="Chain ID" mono value={health ? `${health.chainId}` : '—'} />
<Stat icon={<Gauge size={14} />} label="Gas price" mono value={health ? `${health.gasPriceGwei.toFixed(2)} gwei` : '—'} />
<Stat icon={<Activity size={14} />} label="RPC latency" mono value={health ? `${health.latencyMs} ms` : '—'} />
<Stat icon={<Box size={14} />} label="Total blocks" mono value={stats ? stats.total_blocks.toLocaleString() : '—'} />
<Stat icon={<Activity size={14} />} label="Total txns" mono value={stats ? stats.total_transactions.toLocaleString() : '—'} />
<Stat icon={<Activity size={14} />} label="Txns today" mono value={stats ? stats.transactions_today.toLocaleString() : '—'} />
<Stat icon={<Cpu size={14} />} label="Addresses" mono value={stats ? stats.total_addresses.toLocaleString() : '—'} />
</div>
<div style={{ padding: '6px 16px 12px', fontSize: 11, color: '#6b7280', display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<span>
RPC: <a href={endpoints.chain138.rpcUrl} target="_blank" rel="noreferrer" className="mono" style={{ color: '#9ca3af' }}>{endpoints.chain138.rpcUrl}</a>
</span>
<span>
Explorer: <a href={endpoints.chain138.blockExplorerUrl} target="_blank" rel="noreferrer" style={{ color: '#9ca3af' }}>{endpoints.chain138.blockExplorerUrl} <ExternalLink size={10} style={{ verticalAlign: 'middle' }} /></a>
</span>
{error && <span style={{ color: '#ef4444' }}>Error: {error}</span>}
</div>
</div>
);
}
function Stat({ icon, label, value, mono, href }: { icon: React.ReactNode; label: string; value: string; mono?: boolean; href?: string }) {
const valueEl = (
<span className={mono ? 'mono' : ''} style={{ fontSize: 15, fontWeight: 600 }}>{value}</span>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<span style={{ fontSize: 11, color: '#9ca3af', display: 'flex', alignItems: 'center', gap: 4 }}>{icon} {label}</span>
{href ? <a href={href} target="_blank" rel="noreferrer" style={{ color: '#60a5fa', textDecoration: 'none' }}>{valueEl}</a> : valueEl}
</div>
);
}

View File

@@ -1,92 +0,0 @@
import { useLatestTransactions } from '../../hooks/useLatestTransactions';
import { explorerTxUrl, explorerAddressUrl, type ExplorerTx } from '../../services/explorer';
import { formatEther } from 'ethers';
import { Activity } from 'lucide-react';
const shortHash = (h: string) => `${h.slice(0, 10)}${h.slice(-6)}`;
const shortAddr = (a: string) => `${a.slice(0, 6)}${a.slice(-4)}`;
const formatMETA = (wei: string) => {
try { return `${Number(formatEther(BigInt(wei))).toFixed(4)} META`; } catch { return `${wei} wei`; }
};
const relativeTime = (iso: string) => {
const then = new Date(iso).getTime();
const dt = Date.now() - then;
if (dt < 60_000) return `${Math.max(1, Math.round(dt / 1000))}s ago`;
if (dt < 3_600_000) return `${Math.round(dt / 60_000)}m ago`;
return `${Math.round(dt / 3_600_000)}h ago`;
};
interface Props {
/** Max rows to show (default 10). */
limit?: number;
/** Custom card header label — defaults to "Live Chain-138 Transactions". */
title?: string;
}
/**
* Renders the most recent on-chain transactions from SolaceScan.
* Degraded state shows the error message; empty state shows a one-liner.
* Links every hash/address to the explorer.
*/
export default function LiveTransactionsPanel({ limit = 10, title = 'Live Chain-138 Transactions' }: Props) {
const { transactions, loading, error, lastUpdated } = useLatestTransactions(limit);
return (
<div className="dashboard-card live-transactions-card">
<div className="card-header">
<h3><Activity size={16} /> {title}</h3>
<span className="small" style={{ color: '#6b7280' }}>
{error
? <span style={{ color: '#ef4444' }}>RPC degraded · {error}</span>
: loading
? 'loading…'
: `${transactions.length} tx · ${lastUpdated ? lastUpdated.toLocaleTimeString() : '—'}`}
</span>
</div>
<div className="live-transactions-list">
{!loading && transactions.length === 0 && !error && (
<div style={{ padding: 12, color: '#6b7280', fontSize: 12 }}>
No transactions returned yet SolaceScan may be indexing.
</div>
)}
{transactions.map((tx: ExplorerTx) => (
<div
key={tx.hash}
className="live-tx-row"
style={{
display: 'grid',
gridTemplateColumns: '1.3fr 1fr 1fr 0.9fr 0.7fr 0.4fr',
gap: 8,
padding: '6px 12px',
borderBottom: '1px solid rgba(255,255,255,0.04)',
fontSize: 11,
alignItems: 'center',
}}
>
<a href={explorerTxUrl(tx.hash)} target="_blank" rel="noreferrer" className="mono" style={{ color: '#60a5fa' }}>
{shortHash(tx.hash)}
</a>
<a href={explorerAddressUrl(tx.from.hash)} target="_blank" rel="noreferrer" className="mono" style={{ color: '#cbd5e1' }}>
{shortAddr(tx.from.hash)}
</a>
<span className="mono" style={{ color: tx.to ? '#cbd5e1' : '#6b7280' }}>
{tx.to ? shortAddr(tx.to.hash) : '— contract create —'}
</span>
<span className="mono">{formatMETA(tx.value)}</span>
<span className="small" style={{ color: '#6b7280' }}>{relativeTime(tx.timestamp)}</span>
<span style={{
color: tx.status === 'error' ? '#ef4444' : tx.status === 'ok' ? '#22c55e' : '#eab308',
fontSize: 9,
}}>
{tx.status ?? 'pending'}
</span>
</div>
))}
</div>
<div style={{ padding: '6px 12px', fontSize: 10, color: '#6b7280' }}>
Source: <a href="https://explorer.d-bis.org" target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>SolaceScan Explorer</a>
{' · polls every 15s · Blockscout v2 /transactions'}
</div>
</div>
);
}

View File

@@ -1,42 +0,0 @@
import type { OnChainBalance } from '../../services/chain138';
import { endpoints } from '../../config/endpoints';
import { explorerAddressUrl } from '../../services/explorer';
interface Props {
address: string;
balance: OnChainBalance | undefined;
loading: boolean;
compact?: boolean;
}
/**
* Renders a small pill that flips between three states:
* ● live · chain 138 — on-chain read succeeded
* ○ fetching… — initial RPC call in flight
* ○ off-chain — RPC call failed / empty state
*
* When `balance` is present, the pill becomes a link to the address page on
* SolaceScan. We never hide this tag once a walletAddress is present —
* otherwise there's no way to tell "not wired" apart from "backend is down".
*/
export default function OnChainBalanceTag({ address, balance, loading, compact }: Props) {
const color = balance ? '#22c55e' : loading ? '#eab308' : '#6b7280';
const label = balance ? `● live · chain ${endpoints.chain138.chainId}` : loading ? '○ fetching…' : '○ off-chain';
const href = explorerAddressUrl(address);
const style: React.CSSProperties = {
fontSize: compact ? 9 : 10,
color,
textDecoration: 'none',
letterSpacing: 0.2,
};
return (
<a href={href} target="_blank" rel="noreferrer" title={`View ${address} on SolaceScan`} style={style}>
{label}
{balance && (
<span className="mono" style={{ color: '#60a5fa', marginLeft: 6 }}>
{Number(balance.balanceEth).toFixed(4)} META
</span>
)}
</a>
);
}

View File

@@ -1,265 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useBreakpoint } from '../../hooks/useBreakpoint';
import {
LayoutDashboard, Zap, Building2, Landmark, FileText, Shield, CheckSquare,
Settings, LogOut, ChevronLeft, ChevronRight, Bell, User, Copy,
ExternalLink, ChevronDown, GitBranch, Menu, X,
} from 'lucide-react';
const navItems = [
{ id: 'dashboard', label: 'Overview', icon: LayoutDashboard, path: '/dashboard' },
{ id: 'transaction-builder', label: 'Transaction Builder', icon: Zap, path: '/transaction-builder' },
{ id: 'transactions', label: 'Transactions', icon: GitBranch, path: '/transactions' },
{ id: 'accounts', label: 'Accounts', icon: Building2, path: '/accounts' },
{ id: 'treasury', label: 'Treasury', icon: Landmark, path: '/treasury' },
{ id: 'reporting', label: 'Reporting', icon: FileText, path: '/reporting' },
{ id: 'compliance', label: 'Compliance & Risk', icon: Shield, path: '/compliance' },
{ id: 'settlements', label: 'Settlements', icon: CheckSquare, path: '/settlements' },
];
interface PortalLayoutProps {
children: React.ReactNode;
}
export default function PortalLayout({ children }: PortalLayoutProps) {
const { user, wallet, disconnect } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const { isMobile } = useBreakpoint();
const [collapsed, setCollapsed] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const currentPath = location.pathname;
/* Sync drawer closed state with route and breakpoint changes.
These setState-in-effect calls are intentional: the drawer must
close in response to external inputs (router location, viewport
media query) — this is exactly what an effect is for. */
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDrawerOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!isMobile) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDrawerOpen(false);
}
}, [isMobile]);
/* Close drawer + menus on Escape (external subscription, allowed in effects) */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setDrawerOpen(false);
setShowUserMenu(false);
setShowNotifications(false);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
/* Lock body scroll while drawer is open on mobile */
useEffect(() => {
if (drawerOpen) {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}
}, [drawerOpen]);
const copyAddress = () => {
if (wallet?.address) {
navigator.clipboard.writeText(wallet.address);
}
};
return (
<div className="portal-layout">
<div className="portal-topbar">
<div className="portal-topbar-left">
{/* Mobile hamburger — hidden ≥ lg via CSS */}
<button
className="portal-menu-toggle"
aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
aria-expanded={drawerOpen}
aria-controls="portal-primary-nav"
onClick={() => setDrawerOpen(v => !v)}
>
{drawerOpen ? <X size={20} /> : <Menu size={20} />}
</button>
<div
className="portal-logo"
role="link"
tabIndex={0}
onClick={() => navigate('/dashboard')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/dashboard'); }}
>
<Building2 size={22} color="#3b82f6" />
{!collapsed && (
<div className="portal-logo-text">
<span className="portal-logo-name">Solace Bank Group</span>
<span className="portal-logo-plc">PLC</span>
</div>
)}
</div>
</div>
<div className="portal-topbar-center">
<div className="portal-env-badge">
<span className="env-dot" />
Production
</div>
</div>
<div className="portal-topbar-right">
<div className="portal-notif-wrapper">
<button
className="portal-icon-btn"
aria-label="Notifications"
aria-haspopup="true"
aria-expanded={showNotifications}
onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}
>
<Bell size={18} />
<span className="portal-notif-badge" aria-label="3 unread">3</span>
</button>
{showNotifications && (
<div className="portal-dropdown notifications-dropdown" role="menu">
<div className="portal-dropdown-header">Notifications</div>
<div className="portal-dropdown-item warning" role="menuitem">
<span className="dropdown-dot warning" />
<div>
<div className="dropdown-title">AML Alert</div>
<div className="dropdown-desc">Unusual pattern on ACC-001</div>
</div>
</div>
<div className="portal-dropdown-item info" role="menuitem">
<span className="dropdown-dot info" />
<div>
<div className="dropdown-title">Settlement Confirmed</div>
<div className="dropdown-desc">TX-2024-0847 settled</div>
</div>
</div>
<div className="portal-dropdown-item" role="menuitem">
<span className="dropdown-dot success" />
<div>
<div className="dropdown-title">Report Ready</div>
<div className="dropdown-desc">Q4 IFRS Balance Sheet</div>
</div>
</div>
</div>
)}
</div>
<div className="portal-user-wrapper">
<button
className="portal-user-btn"
aria-label="Account menu"
aria-haspopup="true"
aria-expanded={showUserMenu}
onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}
>
<div className="portal-avatar">
<User size={14} />
</div>
<div className="portal-user-info">
<span className="portal-user-name">{user?.displayName || 'User'}</span>
<span className="portal-user-role">{user?.role?.replace('_', ' ') || 'Admin'}</span>
</div>
<ChevronDown size={12} />
</button>
{showUserMenu && (
<div className="portal-dropdown user-dropdown" role="menu">
<div className="portal-dropdown-header">Account</div>
<div className="portal-dropdown-section">
<div className="portal-wallet-addr">
<span className="mono">{wallet?.address ? `${wallet.address.slice(0, 8)}...${wallet.address.slice(-6)}` : '—'}</span>
<button className="copy-btn" onClick={copyAddress} title="Copy address" aria-label="Copy wallet address"><Copy size={12} /></button>
</div>
<div className="portal-wallet-bal">
<span>{wallet?.balance ? `${parseFloat(wallet.balance).toFixed(4)} ETH` : '—'}</span>
<span className="chain-badge">Chain {wallet?.chainId || 1}</span>
</div>
</div>
<div className="portal-dropdown-divider" />
<button className="portal-dropdown-action" role="menuitem" onClick={() => navigate('/settings')}>
<Settings size={14} /> Settings
</button>
<button className="portal-dropdown-action" role="menuitem" onClick={() => window.open('https://etherscan.io', '_blank')}>
<ExternalLink size={14} /> View on Explorer
</button>
<div className="portal-dropdown-divider" />
<button className="portal-dropdown-action danger" role="menuitem" onClick={disconnect}>
<LogOut size={14} /> Disconnect Wallet
</button>
</div>
)}
</div>
</div>
</div>
<div className="portal-body">
{/* Drawer backdrop — visible only on < md and only when drawer is open */}
<div
className={`portal-drawer-backdrop ${drawerOpen ? 'visible' : ''}`}
onClick={() => setDrawerOpen(false)}
aria-hidden="true"
/>
<nav
id="portal-primary-nav"
aria-label="Primary"
className={`portal-sidebar ${collapsed ? 'collapsed' : ''} ${drawerOpen ? 'drawer-open' : ''}`}
>
<div className="portal-nav-items">
{navItems.map(item => {
const Icon = item.icon;
const isActive = currentPath === item.path || (item.path !== '/dashboard' && currentPath.startsWith(item.path));
return (
<button
key={item.id}
className={`portal-nav-item ${isActive ? 'active' : ''}`}
onClick={() => navigate(item.path)}
title={collapsed ? item.label : undefined}
aria-current={isActive ? 'page' : undefined}
>
<Icon size={18} />
{!collapsed && <span>{item.label}</span>}
{isActive && <div className="nav-active-indicator" />}
</button>
);
})}
</div>
<div className="portal-nav-footer">
<button
className="portal-nav-item"
onClick={() => navigate('/settings')}
title={collapsed ? 'Settings' : undefined}
>
<Settings size={18} />
{!collapsed && <span>Settings</span>}
</button>
<button
className="portal-collapse-btn"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? 'Expand navigation' : 'Collapse navigation'}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
</nav>
<main id="main-content" tabIndex={-1} className="portal-content">
{children}
</main>
</div>
</div>
);
}

View File

@@ -1,51 +0,0 @@
import { TRANSACTION_STATES, type StateTransition, type TransactionState } from '../../services/orchestrator';
interface StateMachineViewProps {
current: TransactionState;
transitions: StateTransition[];
}
/**
* Renders the 12-state transaction machine from the architecture note
* §8. Visited states are highlighted in the order they were entered;
* the current state is emphasised. Intended as an audit-friendly view
* for the /transactions page, NOT a full graph editor.
*/
export default function StateMachineView({ current, transitions }: StateMachineViewProps) {
const visited = new Set<string>(transitions.map((t) => t.to_state));
if (transitions.length > 0 && transitions[0].from_state === null) {
visited.add(transitions[0].to_state);
}
return (
<div className="state-machine-view">
<div className="state-machine-grid">
{TRANSACTION_STATES.map((state) => {
const isCurrent = state === current;
const isVisited = visited.has(state);
const isTerminal = state === 'COMMITTED' || state === 'ABORTED' || state === 'CLOSED';
const classes = [
'state-pill',
isCurrent ? 'state-pill--current' : '',
!isCurrent && isVisited ? 'state-pill--visited' : '',
!isVisited ? 'state-pill--pending' : '',
isTerminal ? 'state-pill--terminal' : '',
]
.filter(Boolean)
.join(' ');
return (
<div key={state} className={classes} data-testid={`state-${state}`}>
<span className="state-pill-dot" aria-hidden="true" />
<span className="state-pill-label">{state.replace(/_/g, ' ')}</span>
</div>
);
})}
</div>
<div className="state-machine-legend">
<span className="legend-item"><span className="dot dot--current" />current</span>
<span className="legend-item"><span className="dot dot--visited" />visited</span>
<span className="legend-item"><span className="dot dot--pending" />not yet reached</span>
</div>
</div>
);
}

View File

@@ -1,130 +0,0 @@
/**
* Central endpoint configuration for the Solace Bank Group PLC portal.
*
* All URLs can be overridden at build time via Vite env vars (VITE_*) so the
* same codebase can target staging / production / local mocks without a rebuild.
*
* Live backend status (verified 2026-04-19):
* - chain138.rpc LIVE (Besu QBFT, ChainID 138 / 0x8a)
* - explorer.api LIVE (SolaceScan / Blockscout v2; CORS *)
* - proxmox.api LIVE but CF-Access protected — browser calls
* cannot carry CF-Access JWTs without an SSO flow,
* so this is only reachable via a BFF today.
* - dbisCore.api NOT DEPLOYED (api.dbis-core.d-bis.org 404/DNS)
*/
export interface EndpointConfig {
chain138: {
rpcUrl: string;
chainId: number;
chainIdHex: `0x${string}`;
name: string;
nativeCurrency: { name: string; symbol: string; decimals: number };
blockExplorerUrl: string;
};
explorer: {
baseUrl: string;
apiBaseUrl: string; // Blockscout v2
};
proxmox: {
apiBaseUrl: string;
/** proxmox-api.d-bis.org is behind Cloudflare Access — direct browser calls
* are blocked. Must be proxied through a BFF that holds a CF-Access
* Service Token, or the user must complete CF-Access SSO in-browser. */
requiresBff: true;
};
dbisCore: {
apiBaseUrl: string;
/** dbis_core has no deployed public API yet. All methods fall back to
* mock data with a console.warn. Flip this to `false` once the core
* banking API is stood up. */
mocked: true;
};
orchestrator: {
/** CurrenciCombo/orchestrator base URL (plan-state + event stream
* for /transactions page). Empty string means "not deployed —
* fall back to mock demo data". */
baseUrl: string;
deployed: boolean;
};
}
const env = (import.meta as unknown as { env?: Record<string, string> }).env ?? {};
export const endpoints: EndpointConfig = {
chain138: {
// Public gateway; `rpc-core.d-bis.org` is a working internal alias.
rpcUrl: env.VITE_CHAIN138_RPC_URL || 'https://rpc.d-bis.org',
chainId: 138,
chainIdHex: '0x8a',
name: 'DeFi Oracle Meta Mainnet',
nativeCurrency: { name: 'Meta', symbol: 'META', decimals: 18 },
blockExplorerUrl: env.VITE_EXPLORER_BASE_URL || 'https://explorer.d-bis.org',
},
explorer: {
baseUrl: env.VITE_EXPLORER_BASE_URL || 'https://explorer.d-bis.org',
apiBaseUrl: env.VITE_EXPLORER_API_BASE_URL || 'https://api.explorer.d-bis.org',
},
proxmox: {
apiBaseUrl: env.VITE_PROXMOX_API_BASE_URL || 'https://proxmox-api.d-bis.org',
requiresBff: true,
},
dbisCore: {
apiBaseUrl: env.VITE_DBIS_CORE_API_BASE_URL || 'https://api.dbis-core.d-bis.org',
mocked: true,
},
orchestrator: {
baseUrl: env.VITE_ORCHESTRATOR_URL || '',
deployed: Boolean(env.VITE_ORCHESTRATOR_URL),
},
};
export type BackendStatus = 'live' | 'bff-required' | 'mocked' | 'degraded';
export interface BackendDescriptor {
id: 'chain138' | 'explorer' | 'proxmox' | 'dbisCore' | 'orchestrator';
name: string;
status: BackendStatus;
url: string;
note: string;
}
export const backendCatalog: BackendDescriptor[] = [
{
id: 'chain138',
name: 'Chain 138 RPC',
status: 'live',
url: endpoints.chain138.rpcUrl,
note: 'DeFi Oracle Meta Mainnet — Besu / QBFT. Read-only browser calls via ethers.',
},
{
id: 'explorer',
name: 'SolaceScan Explorer',
status: 'live',
url: endpoints.explorer.apiBaseUrl,
note: 'Blockscout v2 API. CORS * — safe for direct browser calls.',
},
{
id: 'proxmox',
name: 'Proxmox API',
status: 'bff-required',
url: endpoints.proxmox.apiBaseUrl,
note: 'Live but behind Cloudflare Access. Needs a BFF/service token; mocked in the browser.',
},
{
id: 'dbisCore',
name: 'DBIS Core Banking',
status: 'mocked',
url: endpoints.dbisCore.apiBaseUrl,
note: 'No public deployment yet. UI falls back to sample portal data.',
},
{
id: 'orchestrator',
name: 'Transaction Orchestrator',
status: endpoints.orchestrator.deployed ? 'live' : 'mocked',
url: endpoints.orchestrator.baseUrl || '(not deployed)',
note: endpoints.orchestrator.deployed
? 'CurrenciCombo orchestrator — plan state + event stream.'
: 'Orchestrator not yet deployed. /transactions page renders demo plans.',
},
];

View File

@@ -1,153 +0,0 @@
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import { BrowserProvider, formatEther } from 'ethers';
import type { AuthState, WalletInfo, PortalUser, UserRole, Permission } from '../types/portal';
interface AuthContextType extends AuthState {
connectWallet: (provider: 'metamask' | 'walletconnect' | 'coinbase') => Promise<void>;
disconnect: () => void;
error: string | null;
}
const AuthContext = createContext<AuthContextType | null>(null);
const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
admin: [
'accounts.view', 'accounts.manage', 'accounts.create',
'transactions.view', 'transactions.create', 'transactions.approve', 'transactions.execute',
'treasury.view', 'treasury.manage', 'treasury.rebalance',
'compliance.view', 'compliance.manage', 'compliance.override',
'reports.view', 'reports.generate', 'reports.export',
'settlements.view', 'settlements.approve',
'admin.users', 'admin.settings', 'admin.audit',
],
treasurer: [
'accounts.view', 'accounts.manage',
'transactions.view', 'transactions.create', 'transactions.approve',
'treasury.view', 'treasury.manage', 'treasury.rebalance',
'reports.view', 'reports.generate', 'reports.export',
'settlements.view', 'settlements.approve',
],
analyst: [
'accounts.view', 'transactions.view', 'treasury.view',
'reports.view', 'reports.generate', 'settlements.view',
],
compliance_officer: [
'accounts.view', 'transactions.view', 'treasury.view',
'compliance.view', 'compliance.manage',
'reports.view', 'reports.generate', 'reports.export',
'settlements.view',
],
auditor: [
'accounts.view', 'transactions.view', 'treasury.view',
'compliance.view', 'reports.view', 'reports.export',
'settlements.view', 'admin.audit',
],
viewer: ['accounts.view', 'transactions.view', 'treasury.view', 'reports.view', 'settlements.view'],
};
const AUTH_STORAGE_KEY = 'solace-auth';
function generateUser(address: string): PortalUser {
return {
id: `usr-${address.slice(2, 10)}`,
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
role: 'admin',
permissions: ROLE_PERMISSIONS['admin'],
institution: 'Solace Bank Group PLC',
department: 'Treasury Operations',
lastLogin: new Date(),
walletAddress: address,
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
isAuthenticated: false,
wallet: null,
user: null,
loading: true,
});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const saved = localStorage.getItem(AUTH_STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setState({
isAuthenticated: true,
wallet: parsed.wallet,
user: { ...parsed.user, lastLogin: new Date(parsed.user.lastLogin) },
loading: false,
});
return;
} catch { /* ignore */ }
}
setState(prev => ({ ...prev, loading: false }));
}, []);
const connectWallet = useCallback(async (providerType: 'metamask' | 'walletconnect' | 'coinbase') => {
setError(null);
setState(prev => ({ ...prev, loading: true }));
try {
let address: string;
let chainId: number;
let balance: string;
const ethereum = (window as unknown as Record<string, unknown>).ethereum as {
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
isMetaMask?: boolean;
isCoinbaseWallet?: boolean;
chainId?: string;
} | undefined;
if (ethereum && (providerType === 'metamask' || providerType === 'coinbase')) {
const accounts = await ethereum.request({ method: 'eth_requestAccounts' }) as string[];
if (!accounts || accounts.length === 0) throw new Error('No accounts returned');
const provider = new BrowserProvider(ethereum as never);
const signer = await provider.getSigner();
address = await signer.getAddress();
const network = await provider.getNetwork();
chainId = Number(network.chainId);
const bal = await provider.getBalance(address);
balance = formatEther(bal);
} else {
// Demo mode — simulate wallet connection for environments without MetaMask
await new Promise(resolve => setTimeout(resolve, 1200));
address = '0x' + Array.from({ length: 40 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
chainId = 1;
balance = (Math.random() * 100).toFixed(4);
}
const wallet: WalletInfo = { address, chainId, balance, provider: providerType };
const user = generateUser(address);
const newState: AuthState = { isAuthenticated: true, wallet, user, loading: false };
setState(newState);
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ wallet, user }));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to connect wallet';
setError(msg);
setState(prev => ({ ...prev, loading: false }));
}
}, []);
const disconnect = useCallback(() => {
setState({ isAuthenticated: false, wallet: null, user: null, loading: false });
localStorage.removeItem(AUTH_STORAGE_KEY);
}, []);
return (
<AuthContext.Provider value={{ ...state, connectWallet, disconnect, error }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View File

@@ -1,81 +0,0 @@
import type { ComponentItem } from '../types';
export const componentCategories = [
{ id: 'assets', label: 'Asset Primitives', icon: '💰' },
{ id: 'actions', label: 'Transaction Actions', icon: '⚡' },
{ id: 'routing', label: 'Routing Components', icon: '🔀' },
{ id: 'compliance', label: 'Compliance Components', icon: '🛡️' },
{ id: 'messaging', label: 'ISO-20022 / Messaging', icon: '📨' },
{ id: 'logic', label: 'Logic / Control', icon: '🔧' },
{ id: 'templates', label: 'Templates', icon: '📋' },
];
export const componentItems: ComponentItem[] = [
// Asset Primitives
{ id: 'fiat-account', label: 'Fiat Account', category: 'assets', icon: '🏦', description: 'Traditional fiat currency account', color: '#22c55e' },
{ id: 'bank-ledger', label: 'Bank Ledger', category: 'assets', icon: '📒', description: 'Core banking ledger entry', color: '#22c55e' },
{ id: 'stablecoin-wallet', label: 'Stablecoin Wallet', category: 'assets', icon: '🪙', description: 'Stablecoin holding wallet', color: '#3b82f6' },
{ id: 'tokenized-security', label: 'Tokenized Security', category: 'assets', icon: '📊', description: 'Tokenized securities instrument', color: '#a855f7' },
{ id: 'commodity-instrument', label: 'Commodity Instrument', category: 'assets', icon: '🛢️', description: 'Physical or digital commodity', color: '#f97316' },
{ id: 'cash-position', label: 'Cash Position', category: 'assets', icon: '💵', description: 'Cash balance position', color: '#22c55e' },
{ id: 'custody-account', label: 'Custody Account', category: 'assets', icon: '🔐', description: 'Custodial holding account', color: '#3b82f6' },
{ id: 'treasury-source', label: 'Treasury Source', category: 'assets', icon: '🏛️', description: 'Treasury management source', color: '#22c55e' },
// Transaction Actions
{ id: 'transfer', label: 'Transfer', category: 'actions', icon: '➡️', description: 'Transfer value between accounts', color: '#3b82f6' },
{ id: 'swap', label: 'Swap', category: 'actions', icon: '🔄', description: 'Swap between asset types', color: '#3b82f6' },
{ id: 'convert', label: 'Convert', category: 'actions', icon: '💱', description: 'FX or asset conversion', color: '#3b82f6' },
{ id: 'split', label: 'Split', category: 'actions', icon: '✂️', description: 'Split value into multiple paths', color: '#3b82f6' },
{ id: 'merge', label: 'Merge', category: 'actions', icon: '🔗', description: 'Merge multiple inputs', color: '#3b82f6' },
{ id: 'lock-unlock', label: 'Lock / Unlock', category: 'actions', icon: '🔒', description: 'Lock or unlock assets', color: '#eab308' },
{ id: 'escrow', label: 'Escrow', category: 'actions', icon: '⏳', description: 'Escrow hold mechanism', color: '#eab308' },
{ id: 'mint-burn', label: 'Mint / Burn', category: 'actions', icon: '🔥', description: 'Mint or burn tokens', color: '#ef4444' },
{ id: 'allocate', label: 'Allocate', category: 'actions', icon: '📤', description: 'Allocate to destinations', color: '#3b82f6' },
{ id: 'rebalance', label: 'Rebalance', category: 'actions', icon: '⚖️', description: 'Portfolio rebalancing', color: '#3b82f6' },
// Routing Components
{ id: 'dex-route', label: 'DEX Route', category: 'routing', icon: '🌐', description: 'Decentralized exchange routing', color: '#a855f7' },
{ id: 'cex-route', label: 'CEX Route', category: 'routing', icon: '🏢', description: 'Centralized exchange routing', color: '#3b82f6' },
{ id: 'otc-desk', label: 'OTC Desk Route', category: 'routing', icon: '🤝', description: 'Over-the-counter desk', color: '#3b82f6' },
{ id: 'banking-rail', label: 'Banking Rail', category: 'routing', icon: '🏦', description: 'Traditional banking rail', color: '#22c55e' },
{ id: 'securities-clearing', label: 'Securities Clearing', category: 'routing', icon: '📋', description: 'Securities clearing route', color: '#a855f7' },
{ id: 'commodity-venue', label: 'Commodity Venue', category: 'routing', icon: '🏭', description: 'Commodity trading venue', color: '#f97316' },
{ id: 'best-execution', label: 'Best Execution Router', category: 'routing', icon: '🎯', description: 'Optimal execution path finder', color: '#22c55e' },
{ id: 'failover-router', label: 'Failover Router', category: 'routing', icon: '🔁', description: 'Fallback routing handler', color: '#eab308' },
// Compliance Components
{ id: 'kyc', label: 'KYC', category: 'compliance', icon: '👤', description: 'Know Your Customer check', color: '#22c55e' },
{ id: 'aml', label: 'AML', category: 'compliance', icon: '🔍', description: 'Anti-Money Laundering screen', color: '#22c55e' },
{ id: 'sanctions', label: 'Sanctions Screening', category: 'compliance', icon: '🚫', description: 'Sanctions list screening', color: '#ef4444' },
{ id: 'jurisdiction', label: 'Jurisdiction Filter', category: 'compliance', icon: '🌍', description: 'Jurisdiction-based filtering', color: '#eab308' },
{ id: 'suitability', label: 'Suitability Check', category: 'compliance', icon: '✅', description: 'Investment suitability assessment', color: '#22c55e' },
{ id: 'travel-rule', label: 'Travel Rule Handler', category: 'compliance', icon: '✈️', description: 'FATF Travel Rule compliance', color: '#3b82f6' },
{ id: 'threshold-alert', label: 'Threshold Alert', category: 'compliance', icon: '⚠️', description: 'Amount threshold monitoring', color: '#eab308' },
{ id: 'approval-gate', label: 'Approval Gate', category: 'compliance', icon: '🚪', description: 'Manual approval checkpoint', color: '#eab308' },
// ISO-20022 / Messaging
{ id: 'pain001', label: 'pain.001', category: 'messaging', icon: '📄', description: 'Customer Credit Transfer Initiation', color: '#a855f7' },
{ id: 'pacs008', label: 'pacs.008', category: 'messaging', icon: '📄', description: 'FI to FI Customer Credit Transfer', color: '#a855f7' },
{ id: 'camt-messages', label: 'camt Messages', category: 'messaging', icon: '📄', description: 'Cash Management messages', color: '#a855f7' },
{ id: 'mapping-transformer', label: 'Mapping Transformer', category: 'messaging', icon: '🔀', description: 'Message format transformer', color: '#a855f7' },
{ id: 'message-validator', label: 'Message Validator', category: 'messaging', icon: '✔️', description: 'ISO-20022 message validation', color: '#a855f7' },
{ id: 'ack-recon', label: 'Ack / Reconciliation', category: 'messaging', icon: '🔄', description: 'Acknowledgement & reconciliation', color: '#a855f7' },
// Logic / Control
{ id: 'if-else', label: 'If / Else', category: 'logic', icon: '🔀', description: 'Conditional branching', color: '#3b82f6' },
{ id: 'branch-jurisdiction', label: 'Branch by Jurisdiction', category: 'logic', icon: '🌍', description: 'Route by legal jurisdiction', color: '#eab308' },
{ id: 'branch-asset', label: 'Branch by Asset Class', category: 'logic', icon: '📊', description: 'Route by asset classification', color: '#3b82f6' },
{ id: 'time-lock', label: 'Time Lock', category: 'logic', icon: '⏰', description: 'Time-based lock condition', color: '#eab308' },
{ id: 'amount-threshold', label: 'Amount Threshold', category: 'logic', icon: '📏', description: 'Amount-based branching', color: '#eab308' },
{ id: 'risk-score', label: 'Risk Score Gate', category: 'logic', icon: '📈', description: 'Risk score evaluation gate', color: '#ef4444' },
{ id: 'manual-approval', label: 'Manual Approval', category: 'logic', icon: '✋', description: 'Human approval step', color: '#eab308' },
{ id: 'retry-policy', label: 'Retry Policy', category: 'logic', icon: '🔁', description: 'Failure retry configuration', color: '#3b82f6' },
// Templates
{ id: 'tpl-cross-border', label: 'Cross-Border Payment', category: 'templates', icon: '🌐', description: 'Multi-jurisdiction payment template', color: '#22c55e' },
{ id: 'tpl-commodity-settlement', label: 'Commodity Settlement', category: 'templates', icon: '🛢️', description: 'Commodity-backed settlement flow', color: '#f97316' },
{ id: 'tpl-stablecoin-offramp', label: 'Stablecoin Off-Ramp', category: 'templates', icon: '🪙', description: 'Stablecoin to fiat off-ramp', color: '#3b82f6' },
{ id: 'tpl-securities-collateral', label: 'Securities Collateral', category: 'templates', icon: '📊', description: 'Securities collateral transfer', color: '#a855f7' },
{ id: 'tpl-treasury-rebalance', label: 'Treasury Rebalancing', category: 'templates', icon: '⚖️', description: 'Treasury position rebalancing', color: '#22c55e' },
{ id: 'tpl-custody-movement', label: 'Custody Movement', category: 'templates', icon: '🔐', description: 'Institutional custody transfer', color: '#3b82f6' },
];

View File

@@ -1,138 +0,0 @@
import type { Account, FinancialSummary, TreasuryPosition, CashForecast, ReportConfig, ComplianceAlert, SettlementRecord, PortalModule } from '../types/portal';
export const portalModules: PortalModule[] = [
{ id: 'dashboard', name: 'Overview', icon: '📊', description: 'Consolidated financial dashboard with real-time portfolio metrics', path: '/dashboard', requiredPermission: 'accounts.view', status: 'active' },
{ id: 'transaction-builder', name: 'Transaction Builder', icon: '⚡', description: 'IDE-style drag-and-drop transaction composition workspace', path: '/transaction-builder', requiredPermission: 'transactions.create', status: 'active' },
{ id: 'accounts', name: 'Accounts', icon: '🏦', description: 'Multi-account and subaccount management with consolidated views', path: '/accounts', requiredPermission: 'accounts.view', status: 'active' },
{ id: 'treasury', name: 'Treasury', icon: '💎', description: 'Treasury operations, cash management, and position monitoring', path: '/treasury', requiredPermission: 'treasury.view', status: 'active' },
{ id: 'reporting', name: 'Reporting', icon: '📋', description: 'IPSAS, US GAAP, and IFRS compliant financial reporting', path: '/reporting', requiredPermission: 'reports.view', status: 'active' },
{ id: 'compliance', name: 'Compliance & Risk', icon: '🛡️', description: 'Regulatory compliance monitoring and risk management', path: '/compliance', requiredPermission: 'compliance.view', status: 'active' },
{ id: 'settlements', name: 'Settlements', icon: '✅', description: 'Settlement lifecycle tracking and clearing operations', path: '/settlements', requiredPermission: 'settlements.view', status: 'active' },
];
export const sampleAccounts: Account[] = [
{
id: 'acc-001', name: 'Main Operating Account', type: 'operating', currency: 'USD',
balance: 45_250_000.00, availableBalance: 44_800_000.00, status: 'active',
institution: 'Solace Bank Group PLC', iban: 'GB82 SLCE 0099 7100 0012 34',
swift: 'SLCEGB2L', lastActivity: new Date(Date.now() - 300000),
subaccounts: [
{ id: 'acc-001a', name: 'Payroll Sub-Account', type: 'operating', currency: 'USD', balance: 2_100_000, availableBalance: 2_100_000, status: 'active', parentId: 'acc-001', institution: 'Solace Bank Group PLC', lastActivity: new Date(Date.now() - 600000) },
{ id: 'acc-001b', name: 'Vendor Payments', type: 'operating', currency: 'USD', balance: 3_500_000, availableBalance: 3_200_000, status: 'active', parentId: 'acc-001', institution: 'Solace Bank Group PLC', lastActivity: new Date(Date.now() - 900000) },
],
},
{
id: 'acc-002', name: 'EUR Treasury Account', type: 'treasury', currency: 'EUR',
balance: 18_750_000.00, availableBalance: 18_500_000.00, status: 'active',
institution: 'Solace Bank Group PLC', iban: 'GB45 SLCE 0099 7200 0056 78',
swift: 'SLCEGB2L', lastActivity: new Date(Date.now() - 1200000),
},
{
id: 'acc-003', name: 'Digital Asset Custody', type: 'custody', currency: 'BTC',
balance: 125.5, availableBalance: 120.0, status: 'active',
institution: 'Solace Bank Group PLC', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38',
lastActivity: new Date(Date.now() - 1800000),
},
{
id: 'acc-004', name: 'Stablecoin Reserve', type: 'stablecoin', currency: 'USDC',
balance: 12_000_000.00, availableBalance: 11_950_000.00, status: 'active',
institution: 'Solace Bank Group PLC', walletAddress: '0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b',
lastActivity: new Date(Date.now() - 2400000),
},
{
id: 'acc-005', name: 'Nostro - Deutsche Bank', type: 'nostro', currency: 'EUR',
balance: 5_200_000.00, availableBalance: 5_200_000.00, status: 'active',
institution: 'Deutsche Bank AG', swift: 'DEUTDEFF',
lastActivity: new Date(Date.now() - 3600000),
},
{
id: 'acc-006', name: 'Collateral Account', type: 'collateral', currency: 'USD',
balance: 8_000_000.00, availableBalance: 0, status: 'active',
institution: 'Solace Bank Group PLC', lastActivity: new Date(Date.now() - 7200000),
},
{
id: 'acc-007', name: 'GBP Settlement Account', type: 'settlement', currency: 'GBP',
balance: 3_400_000.00, availableBalance: 3_150_000.00, status: 'active',
institution: 'Solace Bank Group PLC', iban: 'GB12 SLCE 0099 7300 0098 76',
swift: 'SLCEGB2L', lastActivity: new Date(Date.now() - 5400000),
},
{
id: 'acc-008', name: 'Escrow - Project Alpha', type: 'escrow', currency: 'USD',
balance: 15_000_000.00, availableBalance: 0, status: 'frozen',
institution: 'Solace Bank Group PLC', lastActivity: new Date(Date.now() - 86400000),
},
];
export const financialSummary: FinancialSummary = {
totalAssets: 892_450_000,
totalLiabilities: 654_200_000,
netPosition: 238_250_000,
unrealizedPnL: 4_125_000,
realizedPnL: 12_680_000,
pendingSettlements: 28_500_000,
dailyVolume: 156_000_000,
currency: 'USD',
};
export const treasuryPositions: TreasuryPosition[] = [
{ id: 'pos-1', assetClass: 'Fixed Income', instrument: 'US Treasury 10Y', quantity: 50_000_000, marketValue: 49_250_000, costBasis: 48_500_000, unrealizedPnL: 750_000, currency: 'USD', custodian: 'State Street', maturityDate: new Date('2034-11-15') },
{ id: 'pos-2', assetClass: 'Fixed Income', instrument: 'UK Gilt 5Y', quantity: 20_000_000, marketValue: 19_800_000, costBasis: 20_100_000, unrealizedPnL: -300_000, currency: 'GBP', custodian: 'Euroclear' },
{ id: 'pos-3', assetClass: 'Digital Assets', instrument: 'Bitcoin (BTC)', quantity: 125.5, marketValue: 8_425_000, costBasis: 6_275_000, unrealizedPnL: 2_150_000, currency: 'USD', custodian: 'BitGo' },
{ id: 'pos-4', assetClass: 'Digital Assets', instrument: 'USDC Stablecoin', quantity: 12_000_000, marketValue: 12_000_000, costBasis: 12_000_000, unrealizedPnL: 0, currency: 'USD', custodian: 'Circle' },
{ id: 'pos-5', assetClass: 'FX', instrument: 'EUR/USD Spot', quantity: 18_750_000, marketValue: 20_250_000, costBasis: 19_875_000, unrealizedPnL: 375_000, currency: 'USD', custodian: 'Solace Bank' },
{ id: 'pos-6', assetClass: 'Commodities', instrument: 'Gold (XAU)', quantity: 5_000, marketValue: 11_500_000, costBasis: 9_750_000, unrealizedPnL: 1_750_000, currency: 'USD', custodian: 'HSBC Vault' },
{ id: 'pos-7', assetClass: 'Equities', instrument: 'S&P 500 ETF', quantity: 100_000, marketValue: 45_200_000, costBasis: 42_000_000, unrealizedPnL: 3_200_000, currency: 'USD', custodian: 'State Street' },
{ id: 'pos-8', assetClass: 'Fixed Income', instrument: 'Corporate Bond AAA', quantity: 15_000_000, marketValue: 14_850_000, costBasis: 15_000_000, unrealizedPnL: -150_000, currency: 'USD', custodian: 'JP Morgan', maturityDate: new Date('2028-06-30') },
];
export const cashForecasts: CashForecast[] = Array.from({ length: 30 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() + i);
const base = 45_250_000 + Math.sin(i * 0.3) * 5_000_000;
return {
date,
projected: Math.round(base + (Math.random() - 0.5) * 2_000_000),
actual: i < 3 ? Math.round(base + (Math.random() - 0.5) * 1_000_000) : undefined,
currency: 'USD',
};
});
export const reportConfigs: ReportConfig[] = [
{ id: 'rpt-1', name: 'Balance Sheet - IFRS', standard: 'IFRS', type: 'balance_sheet', period: 'quarterly', status: 'published', generatedAt: new Date(Date.now() - 86400000 * 5), generatedBy: 'J. Thompson' },
{ id: 'rpt-2', name: 'Income Statement - US GAAP', standard: 'US_GAAP', type: 'income_statement', period: 'monthly', status: 'reviewed', generatedAt: new Date(Date.now() - 86400000 * 2), generatedBy: 'M. Chen' },
{ id: 'rpt-3', name: 'Cash Flow Statement - IPSAS', standard: 'IPSAS', type: 'cash_flow', period: 'quarterly', status: 'generated', generatedAt: new Date(Date.now() - 86400000), generatedBy: 'System' },
{ id: 'rpt-4', name: 'Trial Balance - IFRS', standard: 'IFRS', type: 'trial_balance', period: 'monthly', status: 'published', generatedAt: new Date(Date.now() - 86400000 * 3), generatedBy: 'A. Patel' },
{ id: 'rpt-5', name: 'Regulatory Report - US GAAP', standard: 'US_GAAP', type: 'regulatory', period: 'quarterly', status: 'draft', generatedBy: 'System' },
{ id: 'rpt-6', name: 'Position Summary - IFRS', standard: 'IFRS', type: 'position_summary', period: 'daily', status: 'published', generatedAt: new Date(Date.now() - 3600000), generatedBy: 'System' },
{ id: 'rpt-7', name: 'Risk Exposure - IPSAS', standard: 'IPSAS', type: 'risk_exposure', period: 'weekly', status: 'generated', generatedAt: new Date(Date.now() - 86400000 * 1), generatedBy: 'R. Kumar' },
{ id: 'rpt-8', name: 'Compliance Summary - US GAAP', standard: 'US_GAAP', type: 'compliance_summary', period: 'monthly', status: 'reviewed', generatedAt: new Date(Date.now() - 86400000 * 4), generatedBy: 'L. Wright' },
];
export const complianceAlerts: ComplianceAlert[] = [
{ id: 'ca-1', severity: 'critical', category: 'AML', message: 'Unusual transaction pattern detected on ACC-001: 15 transactions exceeding $500K in 24h', timestamp: new Date(Date.now() - 1800000), status: 'open' },
{ id: 'ca-2', severity: 'high', category: 'KYC', message: 'KYC documentation expiring for 3 institutional counterparties within 30 days', timestamp: new Date(Date.now() - 3600000), status: 'acknowledged', assignedTo: 'Compliance Team' },
{ id: 'ca-3', severity: 'medium', category: 'Sanctions', message: 'New OFAC SDN list update — 12 new entries require screening', timestamp: new Date(Date.now() - 7200000), status: 'open' },
{ id: 'ca-4', severity: 'high', category: 'Travel Rule', message: 'Travel rule compliance gap: 2 outbound transfers missing originator data', timestamp: new Date(Date.now() - 10800000), status: 'open' },
{ id: 'ca-5', severity: 'low', category: 'Reporting', message: 'Q4 IPSAS regulatory filing due in 14 days', timestamp: new Date(Date.now() - 14400000), status: 'acknowledged', assignedTo: 'Finance Team' },
{ id: 'ca-6', severity: 'medium', category: 'Risk', message: 'Counterparty credit rating downgrade: Acme Corp (BBB → BB+)', timestamp: new Date(Date.now() - 21600000), status: 'resolved' },
];
export const settlementRecords: SettlementRecord[] = [
{ id: 'stl-1', txId: 'TX-2024-0851', type: 'DVP', status: 'pending', amount: 5_000_000, currency: 'USD', counterparty: 'Goldman Sachs', settlementDate: new Date(Date.now() + 86400000), valueDate: new Date(Date.now() + 86400000), csd: 'DTCC' },
{ id: 'stl-2', txId: 'TX-2024-0852', type: 'PVP', status: 'matched', amount: 12_500_000, currency: 'EUR', counterparty: 'Deutsche Bank', settlementDate: new Date(Date.now() + 172800000), valueDate: new Date(Date.now() + 172800000), csd: 'Euroclear' },
{ id: 'stl-3', txId: 'TX-2024-0853', type: 'FOP', status: 'affirmed', amount: 2_000_000, currency: 'GBP', counterparty: 'Barclays', settlementDate: new Date(), valueDate: new Date(), csd: 'CREST' },
{ id: 'stl-4', txId: 'TX-2024-0854', type: 'internal', status: 'settled', amount: 8_000_000, currency: 'USD', counterparty: 'Internal Transfer', settlementDate: new Date(Date.now() - 86400000), valueDate: new Date(Date.now() - 86400000) },
{ id: 'stl-5', txId: 'TX-2024-0855', type: 'DVP', status: 'failed', amount: 3_250_000, currency: 'USD', counterparty: 'Morgan Stanley', settlementDate: new Date(Date.now() - 172800000), valueDate: new Date(Date.now() - 172800000), csd: 'DTCC' },
{ id: 'stl-6', txId: 'TX-2024-0856', type: 'PVP', status: 'pending', amount: 15_000_000, currency: 'JPY', counterparty: 'Nomura', settlementDate: new Date(Date.now() + 259200000), valueDate: new Date(Date.now() + 259200000) },
];
export const recentActivity = [
{ id: 'ra-1', action: 'Transfer Executed', detail: '$2.5M USD → EUR Treasury Account', timestamp: new Date(Date.now() - 300000), status: 'success' as const },
{ id: 'ra-2', action: 'Settlement Confirmed', detail: 'TX-2024-0847 settled via SWIFT', timestamp: new Date(Date.now() - 1200000), status: 'success' as const },
{ id: 'ra-3', action: 'Compliance Alert', detail: 'AML threshold exceeded on ACC-001', timestamp: new Date(Date.now() - 1800000), status: 'warning' as const },
{ id: 'ra-4', action: 'Report Generated', detail: 'Q4 Balance Sheet (IFRS)', timestamp: new Date(Date.now() - 3600000), status: 'info' as const },
{ id: 'ra-5', action: 'Position Rebalanced', detail: 'Treasury portfolio rebalanced per policy', timestamp: new Date(Date.now() - 5400000), status: 'success' as const },
{ id: 'ra-6', action: 'Settlement Failed', detail: 'TX-2024-0855 DVP failed — counterparty mismatch', timestamp: new Date(Date.now() - 7200000), status: 'error' as const },
{ id: 'ra-7', action: 'New Account Created', detail: 'GBP Settlement Account activated', timestamp: new Date(Date.now() - 10800000), status: 'info' as const },
{ id: 'ra-8', action: 'KYC Review', detail: 'Counterparty due diligence completed for Barclays', timestamp: new Date(Date.now() - 14400000), status: 'success' as const },
];

View File

@@ -1,88 +0,0 @@
import type { ChatMessage, TerminalEntry, ValidationIssue, AuditEntry, SettlementItem, Notification, ThreadEntry } from '../types';
export const sampleMessages: ChatMessage[] = [
{
id: '1',
agent: 'Builder',
content: 'Transaction graph initialized. Drop components from the left panel to begin building your flow.',
timestamp: new Date(Date.now() - 300000),
type: 'agent',
},
{
id: '2',
agent: 'Compliance',
content: 'Compliance engine ready. I\'ll monitor your graph for policy violations as you build.',
timestamp: new Date(Date.now() - 240000),
type: 'agent',
},
{
id: '3',
agent: 'System',
content: 'Environment: Sandbox | Region: Multi-jurisdiction | Protocol: ISO-20022 enabled',
timestamp: new Date(Date.now() - 180000),
type: 'system',
},
];
export const sampleTerminal: TerminalEntry[] = [
{ id: '1', timestamp: new Date(Date.now() - 60000), level: 'info', source: 'system', message: 'Transaction builder initialized' },
{ id: '2', timestamp: new Date(Date.now() - 55000), level: 'info', source: 'compliance', message: 'Compliance engine v3.2.1 loaded' },
{ id: '3', timestamp: new Date(Date.now() - 50000), level: 'success', source: 'routing', message: 'Route optimizer connected to 12 venues' },
{ id: '4', timestamp: new Date(Date.now() - 45000), level: 'info', source: 'iso20022', message: 'Message schemas loaded: pain.001, pacs.008, camt.053' },
{ id: '5', timestamp: new Date(Date.now() - 40000), level: 'warn', source: 'market', message: 'EUR/USD spread widened to 2.3bps' },
{ id: '6', timestamp: new Date(Date.now() - 30000), level: 'info', source: 'system', message: 'Sandbox environment ready' },
];
export const sampleValidation: ValidationIssue[] = [
{ id: '1', severity: 'info', message: 'Graph contains 0 nodes. Add components to begin validation.' },
];
export const sampleAudit: AuditEntry[] = [
{ id: '1', timestamp: new Date(Date.now() - 120000), user: 'system', action: 'SESSION_START', detail: 'Sandbox session initialized' },
{ id: '2', timestamp: new Date(Date.now() - 110000), user: 'system', action: 'ENGINE_LOAD', detail: 'Compliance matrices loaded (47 rules)' },
{ id: '3', timestamp: new Date(Date.now() - 100000), user: 'system', action: 'ENGINE_LOAD', detail: 'Routing engine initialized with 12 venues' },
];
export const sampleSettlement: SettlementItem[] = [
{ id: '1', txId: 'TX-2024-0847', status: 'settled', amount: '1,250,000.00', asset: 'USD', counterparty: 'Acme Corp', timestamp: new Date(Date.now() - 3600000) },
{ id: '2', txId: 'TX-2024-0848', status: 'pending', amount: '500,000.00', asset: 'EUR', counterparty: 'Deutsche Bank', timestamp: new Date(Date.now() - 1800000) },
{ id: '3', txId: 'TX-2024-0849', status: 'in_review', amount: '2,000.00', asset: 'BTC', counterparty: 'BitGo Custody', timestamp: new Date(Date.now() - 900000) },
{ id: '4', txId: 'TX-2024-0850', status: 'awaiting_approval', amount: '750,000.00', asset: 'GBP', counterparty: 'Barclays', timestamp: new Date(Date.now() - 600000) },
];
export const sampleNotifications: Notification[] = [
{ id: '1', title: 'Compliance Update', message: 'New FATF travel rule requirements effective in your jurisdiction', type: 'warning', timestamp: new Date(Date.now() - 600000), read: false },
{ id: '2', title: 'Route Optimization', message: 'New liquidity venue added: Coinbase Prime', type: 'info', timestamp: new Date(Date.now() - 1200000), read: false },
{ id: '3', title: 'Settlement Complete', message: 'TX-2024-0847 settled successfully via SWIFT', type: 'success', timestamp: new Date(Date.now() - 3600000), read: true },
];
export const sampleThreads: ThreadEntry[] = [
{ id: 'thread-1', title: 'Cross-border payment setup', agent: 'Builder', timestamp: new Date(Date.now() - 86400000), messageCount: 12 },
{ id: 'thread-2', title: 'AML compliance review', agent: 'Compliance', timestamp: new Date(Date.now() - 172800000), messageCount: 8 },
{ id: 'thread-3', title: 'SWIFT message generation', agent: 'ISO-20022', timestamp: new Date(Date.now() - 259200000), messageCount: 5 },
];
export const sampleReconciliation = [
{ id: '1', txId: 'TX-2024-0847', internalRef: 'INT-00847', externalRef: 'EXT-SW-4821', status: 'matched', amount: '1,250,000.00', asset: 'USD', timestamp: new Date(Date.now() - 3600000) },
{ id: '2', txId: 'TX-2024-0845', internalRef: 'INT-00845', externalRef: 'EXT-SW-4819', status: 'unmatched', amount: '320,000.00', asset: 'EUR', timestamp: new Date(Date.now() - 7200000) },
{ id: '3', txId: 'TX-2024-0843', internalRef: 'INT-00843', externalRef: 'EXT-CB-1102', status: 'matched', amount: '15.5', asset: 'BTC', timestamp: new Date(Date.now() - 10800000) },
];
export const sampleExceptions = [
{ id: '1', txId: 'TX-2024-0846', type: 'timeout', message: 'Settlement acknowledgement not received within SLA (T+2)', severity: 'error' as const, timestamp: new Date(Date.now() - 5400000) },
{ id: '2', txId: 'TX-2024-0844', type: 'mismatch', message: 'Amount mismatch: expected 500,000.00 EUR, received 499,998.50 EUR', severity: 'warning' as const, timestamp: new Date(Date.now() - 9000000) },
{ id: '3', txId: 'TX-2024-0842', type: 'rejected', message: 'Counterparty rejected: sanctions screening flag on beneficiary', severity: 'error' as const, timestamp: new Date(Date.now() - 14400000) },
];
export const sampleMessageQueue = [
{ id: '1', msgType: 'pain.001', direction: 'outbound' as const, counterparty: 'Deutsche Bank', status: 'sent', timestamp: new Date(Date.now() - 1800000) },
{ id: '2', msgType: 'pacs.008', direction: 'inbound' as const, counterparty: 'Barclays', status: 'received', timestamp: new Date(Date.now() - 2400000) },
{ id: '3', msgType: 'camt.053', direction: 'inbound' as const, counterparty: 'SWIFT', status: 'processing', timestamp: new Date(Date.now() - 3000000) },
];
export const sampleEvents = [
{ id: '1', type: 'NODE_ADDED', detail: 'Fiat Account node added to canvas', timestamp: new Date(Date.now() - 60000) },
{ id: '2', type: 'EDGE_CREATED', detail: 'Connection established: Fiat Account → Transfer', timestamp: new Date(Date.now() - 55000) },
{ id: '3', type: 'VALIDATION_RUN', detail: 'Graph validation completed — 0 errors, 1 warning', timestamp: new Date(Date.now() - 50000) },
{ id: '4', type: 'AGENT_INVOKED', detail: 'Builder Agent queried for routing suggestion', timestamp: new Date(Date.now() - 45000) },
];

View File

@@ -1,54 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getAddressTransactions, type ExplorerTx } from '../services/explorer';
export interface AddressTransactionsState {
transactions: ExplorerTx[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
refresh: () => void;
}
/**
* Fetches recent transactions for a single address from SolaceScan.
* Re-fetches on address change; also re-polls every `pollMs` (default 30s).
* Empty address short-circuits — hook returns an idle state with no error.
*/
export function useAddressTransactions(address: string | null | undefined, limit = 10, pollMs = 30_000): AddressTransactionsState {
const [transactions, setTransactions] = useState<ExplorerTx[]>([]);
const [loading, setLoading] = useState(!!address);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const mounted = useRef(true);
const tick = useCallback(async () => {
if (!address) {
setTransactions([]);
setLoading(false);
return;
}
try {
const txs = await getAddressTransactions(address, limit);
if (!mounted.current) return;
setTransactions(txs);
setError(null);
setLastUpdated(new Date());
} catch (e) {
if (!mounted.current) return;
setError(e instanceof Error ? e.message : String(e));
setTransactions([]);
} finally {
if (mounted.current) setLoading(false);
}
}, [address, limit]);
useEffect(() => {
mounted.current = true;
void tick();
if (!address) return () => { mounted.current = false; };
const id = setInterval(tick, pollMs);
return () => { mounted.current = false; clearInterval(id); };
}, [tick, address, pollMs]);
return { transactions, loading, error, lastUpdated, refresh: () => { void tick(); } };
}

View File

@@ -1,55 +0,0 @@
import { useMediaQuery } from './useMediaQuery';
/**
* Breakpoint constants. Kept in sync with the CSS tokens declared
* in src/styles/tokens.css (--bp-*). If you change one, change both.
*/
export const BREAKPOINTS = {
xs: 0,
sm: 480,
md: 768,
lg: 1024,
xl: 1440,
} as const;
export type BreakpointName = keyof typeof BREAKPOINTS;
/**
* useBreakpoint — returns the current active breakpoint name plus
* a set of convenience booleans. Uses matchMedia internally; does
* not register a window-resize listener.
*/
export function useBreakpoint(): {
current: BreakpointName;
isXs: boolean;
isSm: boolean;
isMd: boolean;
isLg: boolean;
isXl: boolean;
isMobile: boolean; // < md
isTablet: boolean; // md and < lg
isDesktop: boolean; // >= lg
} {
const isSm = useMediaQuery(`(min-width: ${BREAKPOINTS.sm}px)`);
const isMd = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
const isLg = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
const isXl = useMediaQuery(`(min-width: ${BREAKPOINTS.xl}px)`);
let current: BreakpointName = 'xs';
if (isXl) current = 'xl';
else if (isLg) current = 'lg';
else if (isMd) current = 'md';
else if (isSm) current = 'sm';
return {
current,
isXs: current === 'xs',
isSm: current === 'sm',
isMd: current === 'md',
isLg: current === 'lg',
isXl: current === 'xl',
isMobile: !isMd,
isTablet: isMd && !isLg,
isDesktop: isLg,
};
}

View File

@@ -1,46 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getLatestTransactions, type ExplorerTx } from '../services/explorer';
export interface LatestTransactionsState {
transactions: ExplorerTx[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
refresh: () => void;
}
/**
* Polls SolaceScan (Blockscout v2) `/transactions` every `pollMs` and
* returns the top `limit` rows. Never throws — error surfaces in state.
*/
export function useLatestTransactions(limit = 20, pollMs = 15_000): LatestTransactionsState {
const [transactions, setTransactions] = useState<ExplorerTx[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const mounted = useRef(true);
const tick = useCallback(async () => {
try {
const txs = await getLatestTransactions(limit);
if (!mounted.current) return;
setTransactions(txs);
setError(null);
setLastUpdated(new Date());
} catch (e) {
if (!mounted.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (mounted.current) setLoading(false);
}
}, [limit]);
useEffect(() => {
mounted.current = true;
void tick();
const id = setInterval(tick, pollMs);
return () => { mounted.current = false; clearInterval(id); };
}, [tick, pollMs]);
return { transactions, loading, error, lastUpdated, refresh: () => { void tick(); } };
}

View File

@@ -1,54 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getChainHealth, getLatestBlock, type ChainHealth, type LatestBlock } from '../services/chain138';
import { getExplorerStats, type ExplorerStats } from '../services/explorer';
export interface LiveChainState {
health: ChainHealth | null;
latestBlock: LatestBlock | null;
stats: ExplorerStats | null;
loading: boolean;
error: string | null;
lastUpdated: Date | null;
refresh: () => void;
}
/**
* Polls chain-138 RPC + SolaceScan explorer every `pollMs` (default 12s).
* Returns `null` values while loading the first time; never throws.
*/
export function useLiveChain(pollMs = 12_000): LiveChainState {
const [health, setHealth] = useState<ChainHealth | null>(null);
const [latestBlock, setLatestBlock] = useState<LatestBlock | null>(null);
const [stats, setStats] = useState<ExplorerStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const mounted = useRef(true);
const tick = useCallback(async () => {
try {
const [h, b, s] = await Promise.allSettled([getChainHealth(), getLatestBlock(), getExplorerStats()]);
if (!mounted.current) return;
if (h.status === 'fulfilled') setHealth(h.value);
if (b.status === 'fulfilled') setLatestBlock(b.value);
if (s.status === 'fulfilled') setStats(s.value);
const anyError = [h, b, s].find(r => r.status === 'rejected') as PromiseRejectedResult | undefined;
setError(anyError ? String(anyError.reason?.message ?? anyError.reason) : null);
setLastUpdated(new Date());
} catch (e) {
if (!mounted.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (mounted.current) setLoading(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void tick();
const id = setInterval(tick, pollMs);
return () => { mounted.current = false; clearInterval(id); };
}, [tick, pollMs]);
return { health, latestBlock, stats, loading, error, lastUpdated, refresh: () => { void tick(); } };
}

View File

@@ -1,37 +0,0 @@
import { useSyncExternalStore } from 'react';
/**
* useMediaQuery — subscribe to a CSS media query with zero re-render
* churn on unrelated resize events (uses matchMedia's `change` event
* rather than the window resize event).
*
* Safe for SSR: returns `false` on the server when matchMedia is
* undefined. The Vite SPA here is CSR-only but the hook stays SSR-safe
* for reuse.
*/
export function useMediaQuery(query: string): boolean {
const subscribe = (callback: () => void): (() => void) => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return () => {};
}
const mql = window.matchMedia(query);
// Safari <14 used addListener/removeListener — modern Safari,
// Chrome, Firefox, Edge all support add/removeEventListener.
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
}
// Fallback path for very old engines.
mql.addListener(callback);
return () => mql.removeListener(callback);
};
const getSnapshot = (): boolean => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia(query).matches;
};
const getServerSnapshot = (): boolean => false;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

View File

@@ -1,51 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { getNativeBalances, type OnChainBalance } from '../services/chain138';
export interface OnChainBalancesState {
balances: Record<string, OnChainBalance>;
loading: boolean;
error: string | null;
lastUpdated: Date | null;
}
/**
* Fetches native Chain-138 balances for the given addresses and re-polls
* every `pollMs` (default 30s). Addresses array must be stable — pass a
* memoized list, or the hook will re-fetch on every render.
*/
export function useOnChainBalances(addresses: string[], pollMs = 30_000): OnChainBalancesState {
const [balances, setBalances] = useState<Record<string, OnChainBalance>>({});
const [loading, setLoading] = useState(addresses.length > 0);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const mounted = useRef(true);
const key = addresses.join(',');
useEffect(() => {
mounted.current = true;
if (addresses.length === 0) { setLoading(false); return; }
let cancelled = false;
const tick = async () => {
try {
const result = await getNativeBalances(addresses);
if (cancelled || !mounted.current) return;
setBalances(result);
setError(null);
setLastUpdated(new Date());
} catch (e) {
if (cancelled || !mounted.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (!cancelled && mounted.current) setLoading(false);
}
};
void tick();
const id = setInterval(tick, pollMs);
return () => { cancelled = true; mounted.current = false; clearInterval(id); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, pollMs]);
return { balances, loading, error, lastUpdated };
}

View File

@@ -1,10 +0,0 @@
import { useMediaQuery } from './useMediaQuery';
/**
* useOrientation — returns 'portrait' or 'landscape' using CSS media
* query rather than the deprecated window.orientation.
*/
export function useOrientation(): 'portrait' | 'landscape' {
const isPortrait = useMediaQuery('(orientation: portrait)');
return isPortrait ? 'portrait' : 'landscape';
}

View File

@@ -1,12 +0,0 @@
import { useMediaQuery } from './useMediaQuery';
/**
* useReducedMotion — true when the user has requested reduced motion
* at the OS level. CSS already honors this globally via
* @media (prefers-reduced-motion: reduce) in tokens.css; use the hook
* only when a component needs to alter JS animation logic (eg react-flow
* auto-fit animations, chart transitions).
*/
export function useReducedMotion(): boolean {
return useMediaQuery('(prefers-reduced-motion: reduce)');
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
// Load tokens first (no visual changes on their own), then existing
// baseline stylesheet, then responsive overrides, then a11y primitives.
// Order matters: overrides must win at the same specificity.
import './styles/tokens.css'
import './index.css'
import './styles/responsive.css'
import './styles/a11y.css'
import Portal from './Portal'
import { AuthProvider } from './contexts/AuthContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<HashRouter>
<AuthProvider>
<Portal />
</AuthProvider>
</HashRouter>
</StrictMode>,
)

View File

@@ -1,231 +0,0 @@
import { useMemo, useState } from 'react';
import {
Building2, ChevronRight, ChevronDown, Search, Filter, Plus, Download,
ExternalLink, Copy, MoreHorizontal
} from 'lucide-react';
import { sampleAccounts } from '../data/portalData';
import type { Account, AccountType } from '../types/portal';
import { useOnChainBalances } from '../hooks/useOnChainBalances';
import type { OnChainBalance } from '../services/chain138';
import OnChainBalanceTag from '../components/portal/OnChainBalanceTag';
import { explorerAddressUrl } from '../services/explorer';
const typeColors: Record<AccountType, string> = {
operating: '#3b82f6', reserve: '#22c55e', custody: '#a855f7', escrow: '#f97316',
settlement: '#06b6d4', nostro: '#eab308', vostro: '#ec4899', collateral: '#6366f1',
treasury: '#14b8a6', crypto_wallet: '#8b5cf6', stablecoin: '#10b981', omnibus: '#64748b',
};
const formatBalance = (amount: number, currency: string) => {
if (currency === 'BTC') return `${amount.toFixed(4)} BTC`;
if (currency === 'USDC') return `$${amount.toLocaleString()}`;
const sym = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '';
if (Math.abs(amount) >= 1_000_000) return `${sym}${(amount / 1_000_000).toFixed(2)}M`;
return `${sym}${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
interface AccountRowProps {
account: Account;
level?: number;
onChainBalances: Record<string, OnChainBalance>;
balancesLoading: boolean;
}
function AccountRow({ account, level = 0, onChainBalances, balancesLoading }: AccountRowProps) {
const [expanded, setExpanded] = useState(false);
const hasChildren = account.subaccounts && account.subaccounts.length > 0;
const onChain = account.walletAddress ? onChainBalances[account.walletAddress] : undefined;
return (
<>
<div className={`account-table-row level-${level}`} style={{ paddingLeft: `${16 + level * 24}px` }}>
<div className="account-table-name">
{hasChildren ? (
<button className="expand-btn" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span className="expand-placeholder" />
)}
<span className="account-type-dot" style={{ background: typeColors[account.type] }} />
<div>
<span className="account-name-text">{account.name}</span>
<span className="account-type-label">{account.type.replace('_', ' ')}</span>
{account.walletAddress && (
<span style={{ display: 'block', marginTop: 2 }}>
<OnChainBalanceTag
address={account.walletAddress}
balance={onChain}
loading={balancesLoading}
compact
/>
</span>
)}
</div>
</div>
<div className="account-table-cell currency">{account.currency}</div>
<div className="account-table-cell mono balance">{formatBalance(account.balance, account.currency)}</div>
<div className="account-table-cell mono available">{formatBalance(account.availableBalance, account.currency)}</div>
<div className="account-table-cell">
<span className={`account-status-badge ${account.status}`}>{account.status}</span>
</div>
<div className="account-table-cell identifier">
{account.iban && <span className="mono small">{account.iban}</span>}
{account.walletAddress && (
<a
href={explorerAddressUrl(account.walletAddress)}
target="_blank"
rel="noreferrer"
className="mono small"
title="View on SolaceScan"
style={{ color: 'inherit', textDecoration: 'underline dotted' }}
>
{account.walletAddress.slice(0, 10)}
</a>
)}
{account.swift && <span className="swift-badge">{account.swift}</span>}
</div>
<div className="account-table-cell">
<span className="mono small">{account.lastActivity.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div className="account-table-cell actions">
<button className="row-action-btn" title="View Details"><ExternalLink size={12} /></button>
<button className="row-action-btn" title="Copy ID"><Copy size={12} /></button>
<button className="row-action-btn" title="More"><MoreHorizontal size={12} /></button>
</div>
</div>
{expanded && hasChildren && account.subaccounts!.map(sub => (
<AccountRow key={sub.id} account={sub} level={level + 1} onChainBalances={onChainBalances} balancesLoading={balancesLoading} />
))}
</>
);
}
export default function AccountsPage() {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [view, setView] = useState<'tree' | 'flat'>('tree');
const onChainAddresses = useMemo(
() => sampleAccounts
.flatMap(a => [a, ...(a.subaccounts || [])])
.filter(a => !!a.walletAddress)
.map(a => a.walletAddress as string),
[],
);
const { balances: onChainBalances, loading: balancesLoading } = useOnChainBalances(onChainAddresses);
const allAccounts = view === 'flat'
? sampleAccounts.flatMap(a => [a, ...(a.subaccounts || [])])
: sampleAccounts;
const filtered = allAccounts.filter(a => {
const matchSearch = a.name.toLowerCase().includes(search.toLowerCase()) ||
a.currency.toLowerCase().includes(search.toLowerCase()) ||
a.type.includes(search.toLowerCase());
const matchType = typeFilter === 'all' || a.type === typeFilter;
return matchSearch && matchType && (view === 'flat' || !a.parentId);
});
const totalBalance = sampleAccounts.reduce((sum, a) => {
if (a.currency === 'USD' || a.currency === 'USDC') return sum + a.balance;
if (a.currency === 'EUR') return sum + a.balance * 1.08;
if (a.currency === 'GBP') return sum + a.balance * 1.27;
if (a.currency === 'BTC') return sum + a.balance * 67_000;
return sum;
}, 0);
return (
<div className="accounts-page">
<div className="page-header">
<div>
<h1><Building2 size={24} /> Account Management</h1>
<p className="page-subtitle">Multi-account and subaccount structures with consolidated views</p>
</div>
<div className="page-header-actions">
<button className="btn-secondary"><Download size={14} /> Export</button>
<button className="btn-primary"><Plus size={14} /> New Account</button>
</div>
</div>
{/* Summary Cards */}
<div className="accounts-summary">
<div className="summary-card">
<span className="summary-label">Total Accounts</span>
<span className="summary-value">{sampleAccounts.length + sampleAccounts.reduce((c, a) => c + (a.subaccounts?.length || 0), 0)}</span>
</div>
<div className="summary-card">
<span className="summary-label">Consolidated Balance (USD eq.)</span>
<span className="summary-value">${(totalBalance / 1_000_000).toFixed(2)}M</span>
</div>
<div className="summary-card">
<span className="summary-label">Active</span>
<span className="summary-value green">{sampleAccounts.filter(a => a.status === 'active').length}</span>
</div>
<div className="summary-card">
<span className="summary-label">Frozen</span>
<span className="summary-value orange">{sampleAccounts.filter(a => a.status === 'frozen').length}</span>
</div>
</div>
{/* Toolbar */}
<div className="table-toolbar">
<div className="table-toolbar-left">
<div className="search-input-wrapper">
<Search size={14} />
<input
type="text"
placeholder="Search accounts..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="filter-group">
<Filter size={14} />
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}>
<option value="all">All Types</option>
<option value="operating">Operating</option>
<option value="treasury">Treasury</option>
<option value="custody">Custody</option>
<option value="settlement">Settlement</option>
<option value="nostro">Nostro</option>
<option value="escrow">Escrow</option>
<option value="collateral">Collateral</option>
<option value="stablecoin">Stablecoin</option>
</select>
</div>
</div>
<div className="table-toolbar-right">
<div className="view-toggle">
<button className={view === 'tree' ? 'active' : ''} onClick={() => setView('tree')}>Tree</button>
<button className={view === 'flat' ? 'active' : ''} onClick={() => setView('flat')}>Flat</button>
</div>
</div>
</div>
{/* Account Table */}
<div className="account-table">
<div className="account-table-header">
<div className="account-table-name">Account</div>
<div className="account-table-cell currency">Currency</div>
<div className="account-table-cell balance">Balance</div>
<div className="account-table-cell available">Available</div>
<div className="account-table-cell">Status</div>
<div className="account-table-cell identifier">Identifier</div>
<div className="account-table-cell">Last Activity</div>
<div className="account-table-cell actions" />
</div>
<div className="account-table-body">
{filtered.map(acc => (
<AccountRow
key={acc.id}
account={acc}
onChainBalances={onChainBalances}
balancesLoading={balancesLoading}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,258 +0,0 @@
import { useMemo, useState } from 'react';
import { Shield, AlertTriangle, CheckCircle2, Clock, Filter, Download, Eye, UserCheck, Activity } from 'lucide-react';
import { complianceAlerts, sampleAccounts } from '../data/portalData';
import { useLiveChain } from '../hooks/useLiveChain';
import { useAddressTransactions } from '../hooks/useAddressTransactions';
import { explorerAddressUrl } from '../services/explorer';
import { endpoints } from '../config/endpoints';
const severityColors: Record<string, string> = {
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6',
};
const statusColors: Record<string, string> = {
open: '#ef4444', acknowledged: '#eab308', resolved: '#22c55e',
};
const complianceMetrics = [
{ label: 'KYC Verified', value: '142', total: '145', pct: 98, color: '#22c55e' },
{ label: 'AML Screening', value: 'Active', total: '47 rules', pct: 100, color: '#3b82f6' },
{ label: 'Sanctions Check', value: 'Current', total: 'OFAC/EU/UN', pct: 100, color: '#a855f7' },
{ label: 'Travel Rule', value: '98.5%', total: 'compliant', pct: 98.5, color: '#14b8a6' },
];
const regulatoryFrameworks = [
{ name: 'FATF Travel Rule', status: 'compliant', lastReview: '2024-03-15', nextReview: '2024-06-15' },
{ name: 'MiCA (EU)', status: 'compliant', lastReview: '2024-02-28', nextReview: '2024-05-28' },
{ name: 'Bank Secrecy Act (US)', status: 'compliant', lastReview: '2024-03-01', nextReview: '2024-06-01' },
{ name: 'FCA Regulations (UK)', status: 'review_needed', lastReview: '2024-01-15', nextReview: '2024-04-15' },
{ name: 'MAS Guidelines (SG)', status: 'compliant', lastReview: '2024-03-10', nextReview: '2024-06-10' },
{ name: 'JFSA Standards (JP)', status: 'compliant', lastReview: '2024-02-20', nextReview: '2024-05-20' },
];
export default function CompliancePage() {
const [severityFilter, setSeverityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const { health, error: liveErr } = useLiveChain();
const tracked = useMemo(
() => sampleAccounts
.flatMap(a => [a, ...(a.subaccounts || [])])
.filter(a => !!a.walletAddress)
.map(a => ({ name: a.name, address: a.walletAddress as string, type: a.type })),
[],
);
const [selectedWallet, setSelectedWallet] = useState(tracked[0]?.address ?? '');
const {
transactions: walletTxs,
loading: walletLoading,
error: walletErr,
} = useAddressTransactions(selectedWallet, 10, 60_000);
const filtered = complianceAlerts.filter(a => {
const matchSev = severityFilter === 'all' || a.severity === severityFilter;
const matchStatus = statusFilter === 'all' || a.status === statusFilter;
return matchSev && matchStatus;
});
const openCount = complianceAlerts.filter(a => a.status === 'open').length;
const criticalCount = complianceAlerts.filter(a => a.severity === 'critical' && a.status !== 'resolved').length;
const selectedWalletName = tracked.find(t => t.address === selectedWallet)?.name ?? '';
return (
<div className="compliance-page">
<div className="page-header">
<div>
<h1><Shield size={24} /> Compliance & Risk Management</h1>
<p className="page-subtitle">Regulatory compliance monitoring, AML/KYC oversight, and risk controls</p>
</div>
<div className="page-header-actions">
<button className="btn-secondary"><Download size={14} /> Export Report</button>
<button className="btn-primary"><UserCheck size={14} /> Run Full Scan</button>
</div>
</div>
{/* On-Chain AML Monitoring strip */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 12,
alignItems: 'center',
marginBottom: 12,
padding: '10px 14px',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 8,
background: 'rgba(255,255,255,0.02)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<Activity size={14} />
<span style={{ fontSize: 12, color: '#cbd5e1' }}>
On-chain AML monitor Chain {endpoints.chain138.chainId}
</span>
<span style={{ fontSize: 11, color: liveErr ? '#ef4444' : '#22c55e' }}>
{liveErr ? `● degraded · ${liveErr}` : health ? `● live · block ${health.blockNumber.toLocaleString()}` : '○ polling…'}
</span>
<span style={{ fontSize: 11, color: '#6b7280' }}>
Tracked custody wallets: {tracked.length}
</span>
</div>
<select
value={selectedWallet}
onChange={e => setSelectedWallet(e.target.value)}
style={{ fontSize: 11, background: 'transparent', color: '#cbd5e1', padding: '4px 6px' }}
>
{tracked.length === 0 && <option value="">No tracked wallets</option>}
{tracked.map(t => (
<option key={t.address} value={t.address}>{t.name} · {t.address.slice(0, 8)}</option>
))}
</select>
</div>
{/* Compliance Metrics */}
<div className="compliance-metrics">
{complianceMetrics.map(m => (
<div key={m.label} className="metric-card">
<div className="metric-header">
<span className="metric-label">{m.label}</span>
<CheckCircle2 size={14} color={m.color} />
</div>
<div className="metric-value" style={{ color: m.color }}>{m.value}</div>
<div className="metric-sub">{m.total}</div>
<div className="metric-bar">
<div className="metric-bar-fill" style={{ width: `${m.pct}%`, background: m.color }} />
</div>
</div>
))}
</div>
<div className="compliance-grid">
{/* Alerts */}
<div className="dashboard-card alerts-table-card">
<div className="card-header">
<h3><AlertTriangle size={16} /> Active Alerts ({openCount} open, {criticalCount} critical)</h3>
<div className="card-header-actions">
<div className="filter-group">
<Filter size={12} />
<select value={severityFilter} onChange={e => setSeverityFilter(e.target.value)}>
<option value="all">All Severity</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div className="filter-group">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="all">All Status</option>
<option value="open">Open</option>
<option value="acknowledged">Acknowledged</option>
<option value="resolved">Resolved</option>
</select>
</div>
</div>
</div>
<div className="alerts-table">
{filtered.map(alert => (
<div key={alert.id} className="alert-table-row">
<span className="alert-sev-badge" style={{ background: severityColors[alert.severity] + '20', color: severityColors[alert.severity], borderColor: severityColors[alert.severity] + '40' }}>
{alert.severity.toUpperCase()}
</span>
<span className="alert-cat">{alert.category}</span>
<span className="alert-msg">{alert.message}</span>
<span className="alert-status-badge" style={{ color: statusColors[alert.status] }}>
{alert.status === 'resolved' ? <CheckCircle2 size={10} /> : alert.status === 'acknowledged' ? <Eye size={10} /> : <Clock size={10} />}
{alert.status}
</span>
<span className="alert-time mono">
{alert.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{alert.assignedTo && <span className="alert-assigned">{alert.assignedTo}</span>}
</div>
))}
</div>
</div>
{/* On-chain transactions for the selected tracked wallet */}
<div className="dashboard-card">
<div className="card-header">
<h3><Activity size={16} /> On-Chain Tx Feed</h3>
<span className="small" style={{ color: '#6b7280' }}>
{selectedWalletName ? selectedWalletName : 'select a wallet above'}
{walletLoading ? ' · loading…' : walletErr ? ` · ${walletErr}` : ''}
</span>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto' }}>
{!walletLoading && walletTxs.length === 0 && !walletErr && (
<div style={{ padding: 12, fontSize: 11, color: '#6b7280' }}>
No on-chain activity for this wallet yet.
{selectedWallet && (
<> View on <a
href={explorerAddressUrl(selectedWallet)}
target="_blank"
rel="noreferrer"
style={{ color: '#60a5fa' }}
>SolaceScan</a>.</>
)}
</div>
)}
{walletTxs.map(tx => (
<div
key={tx.hash}
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 0.8fr 0.5fr',
gap: 6,
padding: '6px 12px',
fontSize: 11,
borderBottom: '1px solid rgba(255,255,255,0.04)',
}}
>
<span className="mono" style={{ color: '#60a5fa' }}>
<a href={`${endpoints.explorer.baseUrl}/tx/${tx.hash}`} target="_blank" rel="noreferrer" style={{ color: 'inherit' }}>
{tx.hash.slice(0, 14)}
</a>
</span>
<span className="mono">
{tx.from.hash.toLowerCase() === selectedWallet.toLowerCase() ? 'OUT →' : 'IN ←'}
{' '}
{(tx.from.hash.toLowerCase() === selectedWallet.toLowerCase() ? tx.to?.hash : tx.from.hash)?.slice(0, 10) ?? '—'}
</span>
<span className="small mono">{new Date(tx.timestamp).toLocaleTimeString()}</span>
<span style={{ color: tx.status === 'error' ? '#ef4444' : '#22c55e', fontSize: 9 }}>
{tx.status ?? 'pending'}
</span>
</div>
))}
</div>
</div>
{/* Regulatory Frameworks */}
<div className="dashboard-card regulatory-card">
<div className="card-header">
<h3><Shield size={16} /> Regulatory Frameworks</h3>
</div>
<div className="regulatory-list">
{regulatoryFrameworks.map(fw => (
<div key={fw.name} className="regulatory-row">
<div className="regulatory-info">
<span className="regulatory-name">{fw.name}</span>
<span className={`regulatory-status ${fw.status}`}>
{fw.status === 'compliant' ? <CheckCircle2 size={10} /> : <AlertTriangle size={10} />}
{fw.status.replace('_', ' ')}
</span>
</div>
<div className="regulatory-dates">
<span className="small">Last: {fw.lastReview}</span>
<span className="small">Next: {fw.nextReview}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,333 +0,0 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
TrendingUp, TrendingDown, DollarSign, Activity, AlertTriangle, Clock,
ArrowUpRight, ArrowDownRight, BarChart3, PieChart, Zap, Building2,
Landmark, FileText, Shield, CheckSquare, ChevronRight, RefreshCw
} from 'lucide-react';
import { financialSummary, sampleAccounts, treasuryPositions, complianceAlerts, recentActivity, portalModules } from '../data/portalData';
import LiveNetworkPanel from '../components/portal/LiveNetworkPanel';
import BackendStatusBar from '../components/portal/BackendStatusBar';
import { useOnChainBalances } from '../hooks/useOnChainBalances';
import { endpoints } from '../config/endpoints';
const formatCurrency = (amount: number, currency = 'USD') => {
if (Math.abs(amount) >= 1_000_000_000) return `${currency === 'USD' ? '$' : ''}${(amount / 1_000_000_000).toFixed(2)}B`;
if (Math.abs(amount) >= 1_000_000) return `${currency === 'USD' ? '$' : ''}${(amount / 1_000_000).toFixed(2)}M`;
if (Math.abs(amount) >= 1_000) return `${currency === 'USD' ? '$' : ''}${(amount / 1_000).toFixed(1)}K`;
return `${currency === 'USD' ? '$' : ''}${amount.toFixed(2)}`;
};
const moduleIcons: Record<string, typeof Zap> = {
'dashboard': BarChart3,
'transaction-builder': Zap,
'accounts': Building2,
'treasury': Landmark,
'reporting': FileText,
'compliance': Shield,
'settlements': CheckSquare,
};
const statusColors: Record<string, string> = {
success: '#22c55e',
warning: '#eab308',
error: '#ef4444',
info: '#3b82f6',
};
const severityColors: Record<string, string> = {
critical: '#ef4444',
high: '#f97316',
medium: '#eab308',
low: '#3b82f6',
};
export default function DashboardPage() {
const navigate = useNavigate();
const [timeRange, setTimeRange] = useState<'1D' | '1W' | '1M' | '3M' | 'YTD'>('1D');
const totalPnL = financialSummary.unrealizedPnL + financialSummary.realizedPnL;
const pnlPositive = totalPnL >= 0;
const assetAllocation = [
{ label: 'Fixed Income', value: 83_900_000, color: '#3b82f6', pct: 39 },
{ label: 'Equities', value: 45_200_000, color: '#22c55e', pct: 21 },
{ label: 'Digital Assets', value: 20_425_000, color: '#a855f7', pct: 10 },
{ label: 'FX', value: 20_250_000, color: '#eab308', pct: 9 },
{ label: 'Commodities', value: 11_500_000, color: '#f97316', pct: 5 },
{ label: 'Cash & Equivalents', value: 33_175_000, color: '#6b7280', pct: 16 },
];
const openAlerts = complianceAlerts.filter(a => a.status !== 'resolved');
const onChainAddresses = useMemo(
() => sampleAccounts.filter(a => !!a.walletAddress).map(a => a.walletAddress as string),
[],
);
const { balances: onChainBalances, loading: balancesLoading } = useOnChainBalances(onChainAddresses);
return (
<div className="dashboard-page">
<div className="dashboard-header">
<div className="dashboard-header-left">
<h1>Portfolio Overview</h1>
<p className="dashboard-subtitle">Solace Bank Group PLC Consolidated View</p>
<div style={{ marginTop: 10 }}><BackendStatusBar /></div>
</div>
<div className="dashboard-header-right">
<div className="time-range-selector">
{(['1D', '1W', '1M', '3M', 'YTD'] as const).map(range => (
<button
key={range}
className={`time-range-btn ${timeRange === range ? 'active' : ''}`}
onClick={() => setTimeRange(range)}
>
{range}
</button>
))}
</div>
<button className="refresh-btn">
<RefreshCw size={14} />
<span>Refresh</span>
</button>
</div>
</div>
{/* KPI Cards Row */}
<div className="kpi-grid">
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Total Assets (AUM)</span>
<DollarSign size={16} className="kpi-icon" />
</div>
<div className="kpi-value">{formatCurrency(financialSummary.totalAssets)}</div>
<div className="kpi-change positive">
<ArrowUpRight size={12} />
<span>+2.3% from yesterday</span>
</div>
</div>
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Net Position</span>
<Activity size={16} className="kpi-icon" />
</div>
<div className="kpi-value">{formatCurrency(financialSummary.netPosition)}</div>
<div className="kpi-change positive">
<ArrowUpRight size={12} />
<span>+1.8% from yesterday</span>
</div>
</div>
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Total P&L</span>
{pnlPositive ? <TrendingUp size={16} className="kpi-icon positive" /> : <TrendingDown size={16} className="kpi-icon negative" />}
</div>
<div className={`kpi-value ${pnlPositive ? 'positive' : 'negative'}`}>
{pnlPositive ? '+' : ''}{formatCurrency(totalPnL)}
</div>
<div className="kpi-sub">
<span>Realized: {formatCurrency(financialSummary.realizedPnL)}</span>
<span>Unrealized: {formatCurrency(financialSummary.unrealizedPnL)}</span>
</div>
</div>
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Daily Volume</span>
<BarChart3 size={16} className="kpi-icon" />
</div>
<div className="kpi-value">{formatCurrency(financialSummary.dailyVolume)}</div>
<div className="kpi-change negative">
<ArrowDownRight size={12} />
<span>-5.1% from yesterday</span>
</div>
</div>
<div className="kpi-card">
<div className="kpi-header">
<span className="kpi-label">Pending Settlements</span>
<Clock size={16} className="kpi-icon" />
</div>
<div className="kpi-value">{formatCurrency(financialSummary.pendingSettlements)}</div>
<div className="kpi-sub">
<span>3 DVP · 1 PVP · 2 FOP</span>
</div>
</div>
<div className="kpi-card alert-card">
<div className="kpi-header">
<span className="kpi-label">Active Alerts</span>
<AlertTriangle size={16} className="kpi-icon warning" />
</div>
<div className="kpi-value">{openAlerts.length}</div>
<div className="kpi-sub">
<span style={{ color: '#ef4444' }}>{openAlerts.filter(a => a.severity === 'critical').length} critical</span>
<span style={{ color: '#f97316' }}>{openAlerts.filter(a => a.severity === 'high').length} high</span>
</div>
</div>
</div>
<div className="dashboard-grid">
{/* Chain 138 live network health — wired to rpc-core.d-bis.org + explorer */}
<div style={{ gridColumn: '1 / -1' }}>
<LiveNetworkPanel />
</div>
{/* Asset Allocation */}
<div className="dashboard-card asset-allocation">
<div className="card-header">
<h3><PieChart size={16} /> Asset Allocation</h3>
</div>
<div className="allocation-chart">
<div className="allocation-bar">
{assetAllocation.map(a => (
<div
key={a.label}
className="allocation-segment"
style={{ width: `${a.pct}%`, background: a.color }}
title={`${a.label}: ${a.pct}%`}
/>
))}
</div>
<div className="allocation-legend">
{assetAllocation.map(a => (
<div key={a.label} className="legend-item">
<span className="legend-dot" style={{ background: a.color }} />
<span className="legend-label">{a.label}</span>
<span className="legend-value">{formatCurrency(a.value)}</span>
<span className="legend-pct">{a.pct}%</span>
</div>
))}
</div>
</div>
</div>
{/* Top Positions */}
<div className="dashboard-card positions-card">
<div className="card-header">
<h3><TrendingUp size={16} /> Top Positions</h3>
<button className="card-action" onClick={() => navigate('/treasury')}>View All <ChevronRight size={12} /></button>
</div>
<div className="positions-table">
<div className="positions-header">
<span>Instrument</span>
<span>Market Value</span>
<span>P&L</span>
</div>
{treasuryPositions.slice(0, 6).map(pos => (
<div key={pos.id} className="position-row">
<div className="position-name">
<span className="position-asset-class">{pos.assetClass}</span>
<span>{pos.instrument}</span>
</div>
<span className="mono">{formatCurrency(pos.marketValue)}</span>
<span className={`mono ${pos.unrealizedPnL >= 0 ? 'positive' : 'negative'}`}>
{pos.unrealizedPnL >= 0 ? '+' : ''}{formatCurrency(pos.unrealizedPnL)}
</span>
</div>
))}
</div>
</div>
{/* Accounts Overview */}
<div className="dashboard-card accounts-overview">
<div className="card-header">
<h3><Building2 size={16} /> Accounts</h3>
<button className="card-action" onClick={() => navigate('/accounts')}>Manage <ChevronRight size={12} /></button>
</div>
<div className="accounts-list">
{sampleAccounts.filter(a => !a.parentId).slice(0, 5).map(acc => {
const onChain = acc.walletAddress ? onChainBalances[acc.walletAddress] : undefined;
return (
<div key={acc.id} className="account-row">
<div className="account-info">
<span className={`account-type-badge ${acc.type}`}>{acc.type}</span>
<span className="account-name">{acc.name}</span>
{acc.walletAddress && (
<span style={{ fontSize: 10, color: onChain ? '#22c55e' : balancesLoading ? '#eab308' : '#6b7280' }}>
{onChain ? `● live · chain ${endpoints.chain138.chainId}` : balancesLoading ? '○ fetching…' : '○ off-chain'}
</span>
)}
</div>
<div className="account-balance">
<span className="mono">
{acc.currency === 'BTC' ? `${acc.balance.toFixed(2)} BTC` : formatCurrency(acc.balance, acc.currency)}
</span>
<span className="account-currency">{acc.currency}</span>
{onChain && (
<span className="mono" style={{ fontSize: 10, color: '#60a5fa', marginTop: 2 }}>
on-chain: {Number(onChain.balanceEth).toFixed(4)} META
</span>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Compliance Alerts */}
<div className="dashboard-card compliance-card">
<div className="card-header">
<h3><Shield size={16} /> Compliance Alerts</h3>
<button className="card-action" onClick={() => navigate('/compliance')}>View All <ChevronRight size={12} /></button>
</div>
<div className="alerts-list">
{complianceAlerts.filter(a => a.status !== 'resolved').slice(0, 4).map(alert => (
<div key={alert.id} className="alert-row">
<span className="alert-severity" style={{ color: severityColors[alert.severity] }}>
{alert.severity.toUpperCase()}
</span>
<span className="alert-category">{alert.category}</span>
<span className="alert-message">{alert.message}</span>
</div>
))}
</div>
</div>
{/* Recent Activity */}
<div className="dashboard-card activity-card">
<div className="card-header">
<h3><Activity size={16} /> Recent Activity</h3>
</div>
<div className="activity-list">
{recentActivity.map(item => (
<div key={item.id} className="activity-row">
<span className="activity-dot" style={{ background: statusColors[item.status] }} />
<div className="activity-content">
<span className="activity-action">{item.action}</span>
<span className="activity-detail">{item.detail}</span>
</div>
<span className="activity-time">
{item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
))}
</div>
</div>
{/* Quick Access Modules */}
<div className="dashboard-card modules-card">
<div className="card-header">
<h3><Zap size={16} /> Quick Access</h3>
</div>
<div className="modules-grid">
{portalModules.filter(m => m.id !== 'dashboard').map(mod => {
const Icon = moduleIcons[mod.id] || Zap;
return (
<button
key={mod.id}
className="module-card"
onClick={() => navigate(mod.path)}
disabled={mod.status !== 'active'}
>
<Icon size={20} />
<span className="module-name">{mod.name}</span>
<span className="module-desc">{mod.description}</span>
{mod.status === 'coming_soon' && <span className="module-badge">Coming Soon</span>}
</button>
);
})}
</div>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More