From ba722ab80543e3a14b2cc8d8099936b878531e36 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:45:37 +0000 Subject: [PATCH 1/5] fix: fail closed on security and payment integrations Co-Authored-By: Patrick Munis --- client/src/pages/app/AseanSingleWindow.tsx | 26 +- client/src/pages/app/CustomsDashboard.tsx | 3 + client/src/pages/app/SecurityAlerts.tsx | 26 +- client/src/pages/app/TraderOnboarding.tsx | 46 ---- client/src/pages/app/WazuhSecurityEvents.tsx | 28 ++- docs/security/defect-discovery-audit.md | 233 +++++++++++++++++ server/_core/context.ts | 9 + server/_core/env.ts | 3 +- server/_core/index.ts | 50 +++- server/_core/permify.ts | 3 +- server/_core/redisRateLimiter.ts | 7 +- server/_core/sdk.ts | 7 +- server/_core/webhookSecretsValidator.ts | 10 +- server/asean.sw.test.ts | 137 +++++----- server/cen.test.ts | 138 +++++----- server/fund-flow.test.ts | 46 +++- server/paymentWorker.ts | 18 +- server/payments.test.ts | 8 +- server/routers/aseanSw.ts | 109 ++------ server/routers/auditEngine.ts | 78 +++--- server/routers/batchPayments.ts | 8 +- server/routers/cen.ts | 64 +---- server/routers/declarations.ts | 19 +- server/routers/fund-flow.ts | 151 +++++------ server/routers/ledger.ts | 153 +++-------- server/routers/mojaloop.ts | 252 ++++++------------- server/routers/onboarding.ts | 25 +- server/routers/payments.ts | 212 +++++++--------- server/routers/rulesOfOrigin.ts | 3 + server/routers/stream.ts | 38 +-- server/routers/tradeFinance.ts | 21 +- server/routers/valuation.ts | 2 +- server/routers/wazuh.ts | 143 ++--------- server/scheduled/documentVaultExpiry.ts | 5 +- server/scheduled/slaBreachEscalation.ts | 5 +- server/v77.test.ts | 28 ++- server/v78.test.ts | 14 +- server/webhooks/cep.ts | 30 +-- server/webhooks/mojaloop.ts | 105 ++++++++ server/webhooks/oga.ts | 17 +- server/webhooks/sanctions.ts | 19 +- shared/const.ts | 1 + 42 files changed, 1127 insertions(+), 1173 deletions(-) create mode 100644 docs/security/defect-discovery-audit.md create mode 100644 server/webhooks/mojaloop.ts diff --git a/client/src/pages/app/AseanSingleWindow.tsx b/client/src/pages/app/AseanSingleWindow.tsx index b2a7d0bb..142d53c5 100644 --- a/client/src/pages/app/AseanSingleWindow.tsx +++ b/client/src/pages/app/AseanSingleWindow.tsx @@ -98,6 +98,7 @@ export default function AseanSingleWindow() { const inboundQ = trpc.aseanSw.listInboundMessages.useQuery(); const statsQ = trpc.aseanSw.getStats.useQuery(); const connectivityQ = trpc.aseanSw.getConnectivityStatus.useQuery(); + const aseanUnavailable = connectionsQ.isError || messagesQ.isError || inboundQ.isError || statsQ.isError || connectivityQ.isError; const testMut = trpc.aseanSw.testConnection.useMutation({ onSuccess: (data) => { @@ -159,6 +160,9 @@ export default function AseanSingleWindow() { return (
+ {aseanUnavailable && ( +

ASEAN Single Window integration unavailable — empty results are not being treated as healthy.

+ )} {/* Header */}
@@ -183,10 +187,10 @@ export default function AseanSingleWindow() { {/* Stats */}
{[ - { label: "Active Connections", value: `${connections.filter(c => c.status === "active").length}/10`, icon: }, - { label: "Total Messages", value: stats?.total ?? 0, icon: }, - { label: "Pending Inbound Acks", value: inbound.filter(m => m.status === "pending_ack").length, icon: }, - { label: "Failed/Rejected", value: (stats?.by_status?.failed ?? 0) + (stats?.by_status?.rejected ?? 0), icon: }, + { label: "Active Connections", value: aseanUnavailable ? "—" : `${connections.filter(c => c.status === "active").length}/10`, icon: }, + { label: "Total Messages", value: aseanUnavailable ? "—" : stats?.total ?? "—", icon: }, + { label: "Pending Inbound Acks", value: aseanUnavailable ? "—" : inbound.filter(m => m.status === "pending_ack").length, icon: }, + { label: "Failed/Rejected", value: aseanUnavailable ? "—" : (stats?.by_status?.failed ?? 0) + (stats?.by_status?.rejected ?? 0), icon: }, ].map((s) => ( @@ -217,7 +221,9 @@ export default function AseanSingleWindow() { RefDestinationDoc TypeUCRStatusSent AtAck RefActions - {messages.length === 0 ? ( + {aseanUnavailable ? ( + Outbound message data unavailable. + ) : messages.length === 0 ? ( No outbound messages yet ) : messages.map((m) => ( @@ -244,7 +250,9 @@ export default function AseanSingleWindow() {
RefSourceDoc TypeUCRStatusReceived AtAck RefActions - {inbound.length === 0 ? ( + {aseanUnavailable ? ( + Inbound message data unavailable. + ) : inbound.length === 0 ? ( No inbound messages ) : inbound.map((m) => ( @@ -266,8 +274,10 @@ export default function AseanSingleWindow() {
- {connectivity.length === 0 ? ( -
Loading connectivity data…
+ {aseanUnavailable ? ( +
Connectivity data unavailable.
+ ) : connectivity.length === 0 ? ( +
No connectivity data.
) : connectivity.map((m: any) => ( diff --git a/client/src/pages/app/CustomsDashboard.tsx b/client/src/pages/app/CustomsDashboard.tsx index 78e923dc..2636953c 100644 --- a/client/src/pages/app/CustomsDashboard.tsx +++ b/client/src/pages/app/CustomsDashboard.tsx @@ -633,6 +633,7 @@ function LiveCargoStream() { { limit: 10 }, { refetchInterval: wsInfo?.pollingIntervalMs ?? false, enabled: !wsInfo?.wsUrl } ); + const streamUnavailable = initialEvents?.unavailable === true || polled?.unavailable === true; useEffect(() => { if (!wsInfo?.wsUrl && polled?.events) { setEvents(polled.events.slice(0, 20)); @@ -660,6 +661,8 @@ function LiveCargoStream() {
{isLoading ? ( Array.from({ length: 5 }).map((_, i) => ) + ) : streamUnavailable ? ( +

Cargo event stream unavailable — live events cannot be displayed.

) : events.length === 0 ? (

No events yet — waiting for stream…

) : ( diff --git a/client/src/pages/app/SecurityAlerts.tsx b/client/src/pages/app/SecurityAlerts.tsx index 466bc65a..104dcbdb 100644 --- a/client/src/pages/app/SecurityAlerts.tsx +++ b/client/src/pages/app/SecurityAlerts.tsx @@ -47,10 +47,10 @@ export default function SecurityAlerts() { const [activeTab, setActiveTab] = useState<"alerts" | "agents" | "playbooks" | "score">("alerts"); // ── Queries ────────────────────────────────────────────────────────────────── - const { data: alerts, isLoading: alertsLoading, refetch: refetchAlerts } = trpc.wazuh.getAlerts.useQuery(undefined, { enabled: isAdmin }); - const { data: agents, isLoading: agentsLoading } = trpc.wazuh.getAgents.useQuery(undefined, { enabled: isAdmin && activeTab === "agents" }); - const { data: playbooks, isLoading: playbooksLoading } = trpc.wazuh.listPlaybooks.useQuery(undefined, { enabled: isAdmin && activeTab === "playbooks" }); - const { data: securityScore } = trpc.wazuh.getSecurityScore.useQuery(); + const { data: alerts, isLoading: alertsLoading, isError: alertsError, refetch: refetchAlerts } = trpc.wazuh.getAlerts.useQuery(undefined, { enabled: isAdmin }); + const { data: agents, isLoading: agentsLoading, isError: agentsError } = trpc.wazuh.getAgents.useQuery(undefined, { enabled: isAdmin && activeTab === "agents" }); + const { data: playbooks, isLoading: playbooksLoading, isError: playbooksError } = trpc.wazuh.listPlaybooks.useQuery(undefined, { enabled: isAdmin && activeTab === "playbooks" }); + const { data: securityScore, isError: securityScoreError } = trpc.wazuh.getSecurityScore.useQuery(); // ── Mutations ───────────────────────────────────────────────────────────────── const triggerPlaybookMutation = trpc.wazuh.triggerPlaybook.useMutation({ @@ -87,7 +87,7 @@ export default function SecurityAlerts() {

Your platform security score

- {securityScore && ( + {securityScore ? (
{(securityScore as any).score ?? "—"}
@@ -106,7 +106,9 @@ export default function SecurityAlerts() {
Failed Checks
- )} + ) : securityScoreError ? ( +

Wazuh security score unavailable — no score is being reported.

+ ) : null} ); } @@ -176,6 +178,10 @@ export default function SecurityAlerts() {
Loading alerts...
+ ) : alertsError ? ( +
+ Wazuh alerts unavailable — no clean result can be reported. +
) : alertList.length === 0 ? (
@@ -234,6 +240,8 @@ export default function SecurityAlerts() {
Loading agents...
+ ) : agentsError ? ( +
Wazuh agents unavailable — no agent state can be reported.
) : (
{agentList.map((agent: any) => ( @@ -255,7 +263,7 @@ export default function SecurityAlerts() {
))} - {agentList.length === 0 && ( + {agentList.length === 0 && !agentsError && (

No agents registered

@@ -271,6 +279,8 @@ export default function SecurityAlerts() {
Loading playbooks...
+ ) : playbooksError ? ( +
Wazuh playbooks unavailable.
) : (
{playbookList.map((pb: any) => ( @@ -294,7 +304,7 @@ export default function SecurityAlerts() {
))} - {playbookList.length === 0 && ( + {playbookList.length === 0 && !playbooksError && (
No playbooks configured
)}
diff --git a/client/src/pages/app/TraderOnboarding.tsx b/client/src/pages/app/TraderOnboarding.tsx index d9393a82..17cb19a8 100644 --- a/client/src/pages/app/TraderOnboarding.tsx +++ b/client/src/pages/app/TraderOnboarding.tsx @@ -621,46 +621,6 @@ const ROLE_OPTIONS = [ border: "border-blue-400/30", portal: "/app/trader", }, - { - value: "customs_officer" as const, - label: "Customs Officer", - description: "Review declarations, assign risk lanes, issue clearances", - icon: Shield, - color: "text-amber-400", - bg: "bg-amber-400/10", - border: "border-amber-400/30", - portal: "/app/customs", - }, - { - value: "oga_officer" as const, - label: "Other Government Agency (OGA) Officer", - description: "Review and approve permits, licences, and certificates", - icon: ClipboardList, - color: "text-purple-400", - bg: "bg-purple-400/10", - border: "border-purple-400/30", - portal: "/app/oga", - }, - { - value: "inspector" as const, - label: "Port Inspector", - description: "Conduct physical inspections, manage cargo tracking", - icon: FileCheck, - color: "text-green-400", - bg: "bg-green-400/10", - border: "border-green-400/30", - portal: "/app/geo/heatmap", - }, - { - value: "finance" as const, - label: "Finance / Revenue Officer", - description: "Monitor duty revenue, manage payments and drawbacks", - icon: Landmark, - color: "text-emerald-400", - bg: "bg-emerald-400/10", - border: "border-emerald-400/30", - portal: "/app/finance", - }, ] as const; type SelfAssignableRole = typeof ROLE_OPTIONS[number]["value"]; @@ -815,12 +775,6 @@ export default function TraderOnboarding() { { setRoleSelected(role); - // Non-trader roles skip the 5-step wizard and go straight to their portal - if (role !== "user") { - const portal = ROLE_OPTIONS.find(r => r.value === role)?.portal ?? "/app/trader"; - toast.success("Role confirmed!", { description: `Redirecting you to the ${role.replace("_", " ")} portal.` }); - setTimeout(() => navigate(portal), 1200); - } }} /> diff --git a/client/src/pages/app/WazuhSecurityEvents.tsx b/client/src/pages/app/WazuhSecurityEvents.tsx index 30f15443..d919b11f 100644 --- a/client/src/pages/app/WazuhSecurityEvents.tsx +++ b/client/src/pages/app/WazuhSecurityEvents.tsx @@ -65,10 +65,10 @@ interface Playbook { export default function WazuhSecurityEvents() { const [triggeringPlaybook, setTriggeringPlaybook] = useState(null); - const { data: alertsData, isLoading: loadingAlerts, refetch: refetchAlerts } = trpc.wazuh.getAlerts.useQuery(); - const { data: agentsData, isLoading: loadingAgents } = trpc.wazuh.getAgents.useQuery(); - const { data: playbooksData, isLoading: loadingPlaybooks } = trpc.wazuh.listPlaybooks.useQuery(); - const { data: secScore } = trpc.wazuh.getSecurityScore.useQuery(); + const { data: alertsData, isLoading: loadingAlerts, isError: alertsError, refetch: refetchAlerts } = trpc.wazuh.getAlerts.useQuery(); + const { data: agentsData, isLoading: loadingAgents, isError: agentsError } = trpc.wazuh.getAgents.useQuery(); + const { data: playbooksData, isLoading: loadingPlaybooks, isError: playbooksError } = trpc.wazuh.listPlaybooks.useQuery(); + const { data: secScore, isError: scoreError } = trpc.wazuh.getSecurityScore.useQuery(); const triggerPlaybook = trpc.wazuh.triggerPlaybook.useMutation({ onSuccess: (data) => { @@ -86,10 +86,14 @@ export default function WazuhSecurityEvents() { const playbooks: Playbook[] = (playbooksData as { playbooks?: Playbook[] } | undefined)?.playbooks ?? []; const score = secScore as { score?: number; grade?: string; unresolved_alerts?: number } | undefined; + const wazuhUnavailable = alertsError || agentsError || playbooksError || scoreError; return (
+ {wazuhUnavailable && ( +

Wazuh data unavailable — empty panels do not indicate a clean security state.

+ )} {/* Header */}
@@ -107,13 +111,13 @@ export default function WazuhSecurityEvents() {
{/* Security Score */} - {score && ( + {score ? (
-

{score.score ?? 0}

+

{score.score ?? "—"}

Security Score

@@ -131,7 +135,7 @@ export default function WazuhSecurityEvents() {
-

{score.unresolved_alerts ?? 0}

+

{score.unresolved_alerts ?? "—"}

Unresolved Alerts

@@ -146,7 +150,9 @@ export default function WazuhSecurityEvents() {
- )} + ) : scoreError ? ( +

Security score unavailable.

+ ) : null} @@ -159,6 +165,8 @@ export default function WazuhSecurityEvents() { {loadingAlerts ? (

Loading alerts…

+ ) : alertsError ? ( +

Security alerts unavailable.

) : alerts.length === 0 ? (

No security alerts.

) : ( @@ -196,6 +204,8 @@ export default function WazuhSecurityEvents() { {loadingAgents ? (

Loading agents…

+ ) : agentsError ? ( +

Wazuh agents unavailable.

) : agents.length === 0 ? (

No agents registered.

) : ( @@ -223,6 +233,8 @@ export default function WazuhSecurityEvents() { {loadingPlaybooks ? (

Loading playbooks…

+ ) : playbooksError ? ( +

Wazuh playbooks unavailable.

) : playbooks.length === 0 ? (

No playbooks available.

) : ( diff --git a/docs/security/defect-discovery-audit.md b/docs/security/defect-discovery-audit.md new file mode 100644 index 00000000..3e00c51b --- /dev/null +++ b/docs/security/defect-discovery-audit.md @@ -0,0 +1,233 @@ +# Codebase defect discovery audit — TradeGateway / NGSWTP single window + +**Repository:** `munisp/singlewindow` (`main`) +**Method:** static, evidence-bound tracing of executable source and deployment manifests. Every finding carries `file:line` evidence and a quoted offending line. `CONFIRMED` means the reachable path was traced end to end in source; `SUSPECTED` means the defect is present but reachability or deployment exposure was not fully established. + +## Executive summary + +The platform presents itself as a national single window that assesses duties, settles them over Mojaloop, mirrors settlement into a TigerBeetle ledger, screens traders against sanctions/risk services, and produces an audit trail for regulators. In the code, most of those guarantees degrade into locally fabricated success: + +1. **Money can be marked settled without any payment rail participating.** `payments.confirm` writes `status: "confirmed"` after a *failed* Temporal call; `mojaloop.getPaymentStatus` — a read query — flips a transfer to `COMMITTED` and posts a revenue-credit ledger entry purely on elapsed wall-clock time; the payment worker treats "Mojaloop unreachable" as a successful transfer. +2. **The ledger of record is optional.** Every value-bearing `ledger.*` procedure falls back to writing a `status: "posted"` row directly into Postgres when the TigerBeetle bridge is unreachable, so bonds, penalties, guarantees and duty transfers exist as authoritative ledger entries that no double-entry system ever accepted. +3. **Compliance gates fail open.** Payment risk scoring defaults to `LOW / APPROVE` when the scorer is down; the fund-flow Permify helper returns `true` on error (and is never called anyway); the final risk-scoring fallback can never produce a red lane; valuation checks return `flagged: false` on DB outage. +4. **Authorization can be self-granted.** Any authenticated user can set their own role to `customs_officer`, `oga_officer`, `inspector` or `finance`; the entire post-clearance audit engine is `publicProcedure`; payment amounts, trader IDs and ledger account IDs are caller-supplied. +5. **Webhook authentication is nominal.** The Mojaloop settlement callback is a public procedure comparing a body field to a hardcoded `"dev-webhook-secret"` default; OGA and CEP webhooks ship committed dev secrets; the sanctions and Keycloak webhooks disable verification entirely when their env var is unset — and the `validateWebhookSecrets()` guard written to prevent exactly this is **never called**. +6. **`JWT_SECRET` has an empty-string default** and is absent from the production env validation list. + +Ranked by money-at-risk × reachability, the top chain is: *self-assign `finance` role → initiate a Mojaloop payment for an arbitrary amount → poll `getPaymentStatus` twice → declaration duty shows settled and customs revenue is credited in the ledger*, with no external system involved and a complete-looking audit trail attributing the settlement to `system`. + +--- + +## Phase 0 — ground truth maps + +### 1. Service map (from source and manifests, not docs) + +| Component | Entry point | Notes | +|---|---|---| +| TypeScript monolith (API + tRPC + SSR) | `server/_core/index.ts:1516` (`PORT` default 3000) | ~102 tRPC routers mounted at `/api/trpc` (`server/_core/index.ts:1500-1508`) | +| Container / k8s base | `Dockerfile:60` `EXPOSE 3000`, `k8s/base/service.yaml:11` | agrees with source | +| Helm chart | `helm/tradegateway/values.yaml:24-25` (port/targetPort 9000) | **disagrees** with source and base manifests | +| Go microservices | `microservices/trade-finance-service/cmd/main.go:597`, `microservices/ucr-service/cmd/main.go:526`, `services/go/declaration-service/main.go:549`, `services/go/wazuh-svc/internal/server/server.go:450` | several duplicate service families in `microservices/` and `services/go/` with different declared ports | +| Python services | `microservices/sanctions-service/main.py:305`, `services/python/deltalake-svc/main.py` | risk/AI/analytics | +| Rust services | `services/rust/tigerbeetle-bridge-rs/`, `services/rust/hs-classifier/` | duplicate of Go TB bridge | +| External dependencies | Temporal, Kafka, Redis, TigerBeetle (via bridge), Mojaloop switch, Keycloak, Permify, OpenSearch, Wazuh, OpenCTI | all reached over HTTP with per-call health probes | + +### 2. Money map + +| Value-bearing table | Written by | Debit / credit / hold semantics | +|---|---|---| +| `declarations.dutyAmount/vatAmount/totalDue` | `server/routers/declarations.ts:269-271` | assessed as flat 10% duty + 15% VAT of the *client-supplied* invoice value; no tariff table, no CIF freight/insurance, no FX conversion | +| `payments` | `server/routers/payments.ts` (`initiate`, `confirm`) | `pending → confirmed`; confirmation is terminal and reversible only by admin paths | +| `payment_queue` | `server/paymentWorker.ts` | `pending → committed`; commit drives balance mirrors | +| `mojaloop_transactions` | `server/routers/mojaloop.ts` | `PENDING → PROCESSING → COMMITTED/ABORTED` | +| `ledger_entries` | `server/routers/ledger.ts`, `server/routers/mojaloop.ts:389`, `:504` | double-entry mirror of TigerBeetle: duty payments, bond deposits/releases, penalties, transit guarantees — `status: "posted"` | +| `drawback_claims`, bonds, guarantees, penalties, refunds | `server/routers/fund-flow.ts` | debits/credits/holds and releases against trader and revenue accounts | + +Holds and releases (bonds, transit guarantees) are represented only as ledger rows; there is no balance invariant check anywhere in the TypeScript path. + +### 3. Trust-boundary map + +* **Unauthenticated HTTP ingress:** `POST /api/webhooks/sanctions-hit` (`server/webhooks/sanctions.ts:40`), `POST /api/webhooks/keycloak-event` (`server/_core/index.ts:1306`), `POST /api/webhooks/oga`, `POST /api/webhooks/cep-event`, `GET /api/verify/:certNumber`, seven `POST /api/scheduled/*` handlers (`server/_core/index.ts:1451-1499`, only the tenant-domain poller authenticates), `mojaloop.webhookCallback` (public tRPC procedure). +* **Payment rails / banks:** Mojaloop switch (`MOJALOOP_URL`), TigerBeetle bridge (`TB_BRIDGE_URL`), payment risk scorer (`PAYMENT_RISK_URL`). +* **KYC / AML / sanctions / risk:** sanctions service, Python ML risk scorer, LLM risk fallback (`invokeLLM`), GNN risk, CEN (WCO), OpenCTI. +* **Identity:** Keycloak (bearer + `X-Auth-Request-Groups` header), Nigeria NIN IdP, Manus session cookie, Permify PDP. +* **Admin control plane:** Wazuh playbooks, OpenSearch ILM (`POST /api/admin/opensearch/setup-ilm`), tenant/site settings, bulk export. + +### 4. Gate map + +| Gate | Where | Actual strength | +|---|---|---| +| `publicProcedure` | `server/_core/trpc.ts:83` | none — used by `auditEngine.*` and `mojaloop.webhookCallback` | +| `protectedProcedure` | `server/_core/trpc.ts:106` | session only; no role, no ownership | +| `adminProcedure` | `server/_core/trpc.ts:108` | role check on `ctx.user.role` — which the user can set themselves (F-01) | +| `keycloakRoleProcedure` | `server/_core/trpc.ts:147` | Keycloak realm/client role **or** DB role equivalence | +| CSRF | `server/_core/trpc.ts` `validateCsrf` | disabled unless `NODE_ENV=production` or `CSRF_ENFORCE_DEV=1` | +| Permify PBAC | `server/_core/permify.ts` | fail-closed, but fully bypassed when `DEMO_MODE=true` (`:53-55`); the `fund-flow` copy fails open and is never called | +| KYC tier gate | `server/routers/declarations.ts:230-238` | genuinely enforced on `submit` | +| Rate limits | `server/_core/security.ts`, mounted `server/_core/index.ts:1288-1297` | Redis-backed with in-memory fallback (per-process, bypassable by fan-out) | +| Amount thresholds | `SUPPORTED_FSPS` min/max (`server/routers/mojaloop.ts:63-130`) | per-FSP only; no aggregate or duty-matching check | +| 2FA / step-up | — | no step-up gate on any money movement | + +### 5. Configuration map (fallback-bearing defaults) + +| Variable | Default | Consequence | +|---|---|---| +| `JWT_SECRET` | `""` (`server/_core/env.ts:3`) | session signing key empty by default; **not** in the production required list (`:169-177`) | +| `MOJALOOP_WEBHOOK_SECRET` | `"dev-webhook-secret"` (`server/routers/mojaloop.ts:47`) | public settlement callback accepts a committed constant | +| `OGA_WEBHOOK_SECRET` | `"tradegateway-oga-webhook-secret-dev"` (`server/webhooks/oga.ts:17`) | permit approvals forgeable | +| `CEP_WEBHOOK_SECRET` | `"tradegateway-cep-webhook-secret-dev"` (`server/webhooks/cep.ts:28`) | alert injection | +| `SANCTIONS_WEBHOOK_SECRET` | `""` (`server/webhooks/sanctions.ts:25`) | empty secret **disables** verification | +| `KEYCLOAK_WEBHOOK_SECRET` | unset | verification block skipped entirely (`server/_core/index.ts:1308`) | +| `DEMO_MODE` | `false` | when `true`: Permify returns `true` for everything and `POST /api/demo/session` mints year-long privileged sessions | +| `REDIS_PASSWORD` | `"tradegateway_redis_2026"` (`server/_core/env.ts:35`) | committed credential | +| `TB_BRIDGE_URL` / `PAYMENT_RISK_URL` | `env.ts` says 8094/8104, `server/routers/ledger.ts` says `tigerbeetle-bridge:8093` / `localhost:8092` | divergent port maps; `8092` and `8093` are each claimed by three different services | +| App port | 3000 in source/Dockerfile/k8s base, 9000 in Helm | one deployment path health-checks a closed port | + +--- + +## Findings + +Severity is money/compliance impact × reachability. "User-facing lie" is the claim the system makes that the code does not honour. + +### F1/F2 — phantom settlement and fabricated integration results (money) + +| # | Title | Evidence | Status | Sev | Blast radius | User-facing lie | +|---|---|---|---|---|---|---| +| 01 | `payments.confirm` marks a payment `confirmed` after the Temporal workflow call fails | `server/routers/payments.ts:196-213` | CONFIRMED | CRITICAL | Every duty payment; declaration proceeds to clearance unpaid | "Payment confirmed" — no workflow, no rail, no ledger participation | +| 02 | `mojaloop.getPaymentStatus` (a **query**) settles the transfer on elapsed time and posts a revenue-credit ledger entry | `server/routers/mojaloop.ts:373-412` | CONFIRMED | CRITICAL | Any transfer ID, by any authenticated user (no ownership check) | "Settled via Mojaloop, fulfilment ``" — nothing left the process | +| 03 | Payment worker treats an unreachable Mojaloop switch as a successful transfer | `server/paymentWorker.ts:88-100` | CONFIRMED | CRITICAL | Whole payment queue; commits and updates balance mirrors | "Committed" with a derived ILP fulfilment | +| 04 | `mojaloop.webhookCallback` is public and compares a body field to a hardcoded default secret with `!==` | `server/routers/mojaloop.ts:47`, `:481-490` | CONFIRMED | CRITICAL | Any internet caller can commit/abort any known transfer ID | "Settlement confirmed by the Mojaloop switch" | +| 05 | Every value-bearing `ledger.*` procedure writes `status: "posted"` straight to Postgres when the TigerBeetle bridge is down | `server/routers/ledger.ts:160-174`, `:357-370`, `:400-413`, `:443-456`, `:486-499` | CONFIRMED | CRITICAL | Duty transfers, bond deposits/releases, penalties, transit guarantees | "Posted to the ledger" — the double-entry system never saw it; `_tag: "offline-stub"` is the only trace | +| 06 | Live Mojaloop transfer response is never inspected; errors are swallowed and the flow continues in simulation | `server/routers/mojaloop.ts:302-327` | CONFIRMED | HIGH | All Mojaloop initiations | "Transfer requested" regardless of a 4xx/5xx from the switch | +| 07 | `mojaloop.initiatePayment` accepts a client-supplied `amount` and an arbitrary `declarationId` with no ownership check and no comparison to `totalDue` | `server/routers/mojaloop.ts:215-232` | CONFIRMED | CRITICAL | Any declaration; pay 1 GHS against a 10 M assessment, then self-settle via #02 | "Duty paid in full" | +| 08 | ILP packet and fulfilment condition are fabricated: fixed 500000 amount, `Math.random()` condition unrelated to the packet | `server/routers/mojaloop.ts:135-145` | CONFIRMED | HIGH | All transfers; ILP crypto is decorative | "ILP packet / condition" implies interledger cryptographic commitment | +| 09 | FX rates are a hardcoded table serving `source: "Bank of Ghana (simulated)"` | `server/routers/mojaloop.ts:170-206` | CONFIRMED | MEDIUM | Any duty conversion | "Bank of Ghana rate, valid 5 minutes" | +| 10 | Mojaloop/ledger money is denominated in **GHS** (Ghana) while the platform, IdP and email identity are **Nigerian** (NGSWTP, NIMC, `tradegateway.gov.ng`) | `server/routers/mojaloop.ts:63-130` vs `server/_core/env.ts:23,62` | CONFIRMED | HIGH | Every amount and ledger entry | jurisdictional coherence of the whole money map | + +### F3 — declared-but-unenforced gates + +| # | Title | Evidence | Status | Sev | Blast radius | User-facing lie | +|---|---|---|---|---|---|---| +| 11 | `onboarding.selectRole` lets any authenticated user write `customs_officer` / `oga_officer` / `inspector` / `finance` into `users.role` | `server/routers/onboarding.ts:272-284` | CONFIRMED | CRITICAL | Every role-gated procedure, including `adminProcedure` peers | "Restricted to roles a user can self-assign" (code comment) | +| 12 | The entire post-clearance audit engine is `publicProcedure`: create/assign/submit findings/close/appeal | `server/routers/auditEngine.ts:7,50,89,102,135-182` | CONFIRMED | CRITICAL | Fabricate or close audits and duty-discrepancy records without a session | "Controlled audit actions" | +| 13 | `validateWebhookSecrets()` / `getWebhookSecret()` — the guard against dev webhook secrets in production — is **never called** anywhere | `server/_core/webhookSecretsValidator.ts:44,135`; no call sites | CONFIRMED | CRITICAL | All four webhook secrets | file header claims "Called at server startup. Throws a fatal error…" | +| 14 | `fund-flow`'s local `checkPermify()` has no call sites — the router's authorization is decorative *and* the helper fails open | `server/routers/fund-flow.ts:88-113` | CONFIRMED | HIGH | ~22 fund-flow money procedures | "Permify-authorized fund flow" | +| 15 | `tradeFinance.createLC` / `createBankGuarantee` / `listBGByTrader` forward caller-supplied `applicantId` / `traderId` | `server/routers/tradeFinance.ts:15-59,63-93,169-177` | CONFIRMED | HIGH | LCs and guarantees issued/enumerated under another trader's identity | "Your instruments" | +| 16 | `batchPayments` queue, account listing and balance queries are `protectedProcedure` with no role or tenant scoping | `server/routers/batchPayments.ts:107,128,180` | CONFIRMED | HIGH | Any trader reads all accounts, balances and the settlement queue | tenant isolation | +| 17 | `emitSecurityEvent`, `verifyAmountSignature`, `signAmount`, `checkIdempotency` are defined and never called, while `systemRouter` renders `getRecentSecurityEvents()` | `server/_core/security.ts`; `server/_core/systemRouter.ts:3,393` | CONFIRMED | MEDIUM | Security event feed is always empty; amount signing unused | "Security events" dashboard | + +### F10/F6 — secrets, crypto and session integrity + +| # | Title | Evidence | Status | Sev | Blast radius | User-facing lie | +|---|---|---|---|---|---|---| +| 18 | `JWT_SECRET` defaults to `""` and is not validated in production | `server/_core/env.ts:3,169-177` | CONFIRMED | CRITICAL | Session forgery for any `openId`, including admin | "Authenticated session" | +| 19 | Sanctions webhook verification is skipped when the secret is empty (the default) | `server/webhooks/sanctions.ts:25,44-52` | CONFIRMED | CRITICAL | Inject sanctions hits; auto-reject arbitrary declarations | "Only the sanctions service can report a hit" | +| 20 | Keycloak webhook skips HMAC entirely when the secret is unset and uses `!==` when set | `server/_core/index.ts:1306-1317` | CONFIRMED | HIGH | Forged identity-management audit events | "Signature-protected" | +| 21 | OGA and CEP webhooks ship working committed dev secrets as defaults | `server/webhooks/oga.ts:17`, `server/webhooks/cep.ts:28` | CONFIRMED | CRITICAL | Forge OGA permit approvals / CEP alerts | "HMAC-SHA256 verified" | +| 22 | Session revocation (JTI blacklist) fails open when Redis is unavailable | `server/_core/sdk.ts:255-263` | CONFIRMED | HIGH | Revoked/compromised sessions keep working | "Session revoked" | +| 23 | Default session lifetime is one year | `server/_core/sdk.ts:211`, `shared/const` `ONE_YEAR_MS` | CONFIRMED | MEDIUM | All sessions | — | +| 24 | `permify.can()` returns `true` for everything when `DEMO_MODE=true`, with no production interlock | `server/_core/permify.ts:53-55` | CONFIRMED | HIGH | All PBAC decisions | "Permify-enforced authorization" | +| 25 | `DEMO_MODE=true` also mounts `POST /api/demo/session`, minting year-long `admin`/`security`/`developer` sessions without authentication | `server/_core/index.ts:1429-1434`, `server/routes/demoAuth.ts:38-83` | SUSPECTED (flag-dependent) | HIGH | Full admin takeover in any environment with the flag on | "Demo mode is isolated presentation access" | + +### F12 — error polarity / fail-open compliance + +| # | Title | Evidence | Status | Sev | Blast radius | User-facing lie | +|---|---|---|---|---|---|---| +| 26 | Payment risk scorer unavailable ⇒ `riskScore 0.10 / LOW / APPROVE` | `server/routers/ledger.ts:300-312` | CONFIRMED | CRITICAL | Every payment risk decision | "Risk assessed LOW — approve" | +| 27 | Final risk-scoring fallback is an HS-code hash bounded to 10–49, so **no declaration can ever be red-laned** when both scorers are down | `server/routers/declarations.ts:150-160` | CONFIRMED | HIGH | All submissions during an outage; physical inspection never triggered | "Automated assessment", lane green/yellow | +| 28 | Fund-flow Redis idempotency returns "not duplicate" on any Redis error | `server/routers/fund-flow.ts:53-61` | CONFIRMED | HIGH | Duplicate refunds/drawbacks/penalties | idempotency-key guarantee | +| 29 | `valuation.checkUndervaluation` returns `flagged: false` when the DB is unavailable | `server/routers/valuation.ts:76-85` | CONFIRMED | HIGH | Undervaluation screening | "Passed valuation" | +| 30 | SLA-breach and document-expiry crons return `{ok: true, processed: 0}` on DB outage (and neither authenticates the caller) | `server/scheduled/slaBreachEscalation.ts:11-16`, `server/scheduled/documentVaultExpiry.ts:14-19`, mounted `server/_core/index.ts:1481-1489` | CONFIRMED | HIGH | Escalations and document revocations silently skipped; scheduler stops retrying | "Run completed successfully" | +| 31 | Keycloak webhook answers `{received: true}` after both audit sinks (DB and OpenSearch) fail | `server/_core/index.ts:1327-1360` | CONFIRMED | HIGH | Identity audit trail gaps | "Event recorded" | +| 32 | Payment-queue insert failure in `payments.initiate` is swallowed | `server/routers/payments.ts` (`catch {}` after queue insert) | CONFIRMED | MEDIUM | Payment exists with nothing to execute it | "Payment initiated" | +| 33 | Keycloak role enrichment failure is swallowed, silently degrading to DB roles | `server/_core/context.ts` (`catch {}` around `verifyKeycloakToken`) | CONFIRMED | MEDIUM | Authorization decisions on stale/degraded role data | "Keycloak roles enforced" | + +### F15/F2 — observability and analytics fiction + +| # | Title | Evidence | Status | Sev | Blast radius | User-facing lie | +|---|---|---|---|---|---|---| +| 34 | `stream.getRecentEvents` fabricates cargo events — including `PAYMENT_RECEIVED`, `CUSTOMS_HOLD_PLACED`, `CLEARANCE_PERMIT_ISSUED` — when Fluvio is offline | `server/routers/stream.ts:32-58,65-77` | CONFIRMED | HIGH | Cargo timelines shown to traders and officers | "Recent cargo events" | +| 35 | ASEAN single-window inbound messages and acknowledgements are synthesised on integration failure | `server/routers/aseanSw.ts:128-177` | CONFIRMED | HIGH | G2G message status/acks | "The member state acknowledged" | +| 36 | Wazuh playbook returns `status: "completed"` after doing nothing; agent list and security score are hardcoded fallbacks | `server/routers/wazuh.ts:90-112,59-73,165-185` | CONFIRMED | HIGH | Incident response and security posture reporting | "Containment playbook completed", "5 agents active, score B+" | +| 37 | CEN (WCO) outage is rendered as empty alert lists and `correlationScore: 0` | `server/routers/cen.ts:9-27,45-46,105-164` | CONFIRMED | HIGH | Enforcement intelligence | "No CEN alerts for this consignment" | +| 38 | Delta Lake analytics service seeds 90 days of `random` trade/revenue records marked `CLEARED` and aggregates them into `/stats` | `services/python/deltalake-svc/main.py:17,64-80,403-415` | CONFIRMED | HIGH | Revenue/duty/clearance dashboards | "Historical platform trade data" | +| 39 | CEP dashboard reports `declarations_processed = total_triggers * 100` | `server/routers/cep.ts:201-222`, `client/src/pages/FlinkCepAlerts.tsx:400` | SUSPECTED | MEDIUM | Throughput reporting | "Declarations processed" | + +### F11/F13/F9/F16 — lifecycle, arithmetic, input, environment + +| # | Title | Evidence | Status | Sev | Blast radius | User-facing lie | +|---|---|---|---|---|---|---| +| 40 | Duty assessment is a flat 10% + 15% VAT on the client-supplied invoice value — no tariff schedule, no CIF, no exemptions, no FX | `server/routers/declarations.ts:256-271` | CONFIRMED | HIGH | Every assessment and therefore every payment amount | "Duties assessed" on a customs tariff basis | +| 41 | `rulesOfOrigin.review` writes a decision without checking the prior status, so terminal certificates can be re-decided | `server/routers/rulesOfOrigin.ts:145-172` | CONFIRMED | HIGH | Origin certificate legal state | "Review of a pending certificate" | +| 42 | Upload route validates only client-declared MIME and filename extension | `server/routes/uploadRoute.ts:15-59` | SUSPECTED | MEDIUM | Document vault contents | "Allowed document type" | +| 43 | Delta Lake write-back interpolates request-supplied table/column/conflict identifiers into SQL | `services/python/deltalake-svc/main.py:313-389` | SUSPECTED | HIGH | Analytics DB, if the endpoint is reachable | "Parameterized SQL" | +| 44 | App port is 3000 in source/Dockerfile/k8s base and 9000 in Helm; `env.ts` and `ledger.ts` disagree on TB-bridge and payment-risk ports; ports 8092/8093 are each claimed by three services | `server/_core/index.ts:1516`, `Dockerfile:60`, `k8s/base/service.yaml:11`, `helm/tradegateway/values.yaml:24-25`, `server/_core/env.ts:88,112` vs `server/routers/ledger.ts:14-15` | CONFIRMED | HIGH | One deployment path routes to a closed port; "service unavailable" fallbacks fire permanently | "Configured integrations" | +| 45 | CSRF enforcement is off unless `NODE_ENV=production` (or an opt-in flag) | `server/_core/trpc.ts` `validateCsrf` | CONFIRMED | LOW | Non-production deployments | — | + +--- + +## Composition chains + +**Chain A — free clearance (no privilege needed beyond a registered trader account).** +1. `declarations.create` + `submit` → duty assessed from the trader's own invoice value (#40). +2. `mojaloop.initiatePayment` with `amount: 1` against that declaration — no ownership check, no comparison to `totalDue` (#07). +3. Poll `mojaloop.getPaymentStatus` twice, ≥15 s apart → transfer flips to `COMMITTED`, a `duty_payment` ledger entry is posted crediting customs revenue, and an audit event records the settlement with `actorType: "system"` (#02). +4. `payments.confirm` (or the same transfer) marks the payment terminal even if Temporal is down (#01). +Result: a cleared declaration, a customs-revenue credit in the ledger of record, and an audit trail that attributes it all to the system. Nothing ever contacted a bank. + +**Chain B — privilege escalation to control-plane.** +`onboarding.selectRole { role: "finance" }` (#11) → `ctx.user.role` is now `finance`, which `KEYCLOAK_TO_DB_ROLE` treats as equivalent to the Keycloak role in `keycloakRoleProcedure` (`server/_core/trpc.ts:147-190`) → role-gated fund-flow, ledger and settlement procedures open up. Combined with #05 (TB bridge down ⇒ direct `posted` ledger writes) and #26 (risk scorer down ⇒ auto-APPROVE), a self-promoted user can mint bond releases and refunds. + +**Chain C — compliance fiction for a regulator.** +`DEMO_MODE=true` or a Permify outage (#24, #14) removes authorization → `auditEngine` mutations are public anyway (#12) → audits are created and closed with fabricated findings → Wazuh reports containment "completed" (#36) → Delta Lake `/stats` reports random but plausible revenue (#38) → CEN reports no alerts (#37). Every regulator-facing surface is green and internally consistent, and none of it is derived from reality. + +**Chain D — unauthenticated money and compliance state changes.** +With `MOJALOOP_WEBHOOK_SECRET` unset, `mojaloop.webhookCallback` accepts `"dev-webhook-secret"` from anyone (#04) → mark any transfer `COMMITTED` and post the ledger entry. With `SANCTIONS_WEBHOOK_SECRET` unset, `POST /api/webhooks/sanctions-hit` accepts unsigned requests (#19) → reject any competitor's declaration. `validateWebhookSecrets()` would have blocked both in production, but it is never called (#13). + +--- + +## Negative results (checked, found sound) + +* `permify.can()` itself fails **closed** on HTTP error, non-OK response and timeout (`server/_core/permify.ts`); the defect is the demo-mode bypass, not the PDP call. +* CSRF double-submit uses `crypto.timingSafeEqual` with a length pre-check (`server/_core/trpc.ts`). +* `security.ts` `verifyAmountSignature` uses constant-time comparison (its problem is that nothing calls it). +* `batchPayments.enqueue` uses a **durable** DB idempotency key (`payment_idempotency_keys`, hash of `enqueue:`), not the fail-open Redis path — the correct pattern already exists in the codebase. +* `mojaloop.initiatePayment` likewise uses durable DB idempotency over user+declaration+amount+FSP+account. +* `declarations.submit` genuinely enforces an approved KYC record before accepting a declaration (`server/routers/declarations.ts:230-238`). +* `declarations.submit` and `getById` check `traderId` ownership. +* `kyc.analyseDocument` verifies document ownership; `kyc.reviewVerification` is `adminProcedure` **and** calls `assertCan`. +* `fraudCases.*` call `requireInvestigator` before any DB work; `heartbeatJobs.*` call `requireAdmin`. +* `rulesOfOrigin.getById` enforces ownership with an officer/admin exception; `rulesOfOrigin.review` does check reviewer role (the gap is prior-state validation only). +* `GET /api/verify/:certNumber` returns 503 on DB outage instead of asserting a certificate result — the correct polarity, and the model for fixing #29/#30. +* `/metrics` is restricted to loopback/RFC-1918 or a bearer token. +* `POST /api/admin/opensearch/setup-ilm` checks `authResult.role !== "admin"`. +* `scheduled/tenantDomainPoller` requires a cron-authenticated request with a task UID — the model for fixing the other six scheduled handlers. +* Money columns in `drizzle/schema.ts` are `decimal`/`numeric`, not floats; the `auditTasks.status` enum matches the values `auditEngine` writes (migration `0032`). +* Neo4j graph traversal depth is clamped and trader identifiers are parameterized. +* Helmet CSP/HSTS/frameguard, input sanitisation and file-upload size/extension/MIME guards are mounted globally. + +--- + +## Residual register (not remediated in this pass) + +| Item | Why deferred | +|---|---| +| #40 tariff-correct duty assessment | needs a real tariff schedule, CIF components and FX policy — product/legal input, not a code defect fix | +| #10 GHS/NGN jurisdiction split | requires a currency decision across schema, FSP list and ledger | +| #38 / #43 Delta Lake service | separate Python service; needs its own PR and deployment review | +| #08 / #09 ILP crypto and FX rates | require a real Mojaloop client and a rate provider | +| #17 unused security helpers | wiring `emitSecurityEvent` across mutations is a broad refactor | +| #44 port map divergence | needs the owner to declare which deployment artifact is authoritative | +| #42 upload content sniffing | needs a magic-byte/AV scanning dependency decision | +| #23 session lifetime | reduced default; refresh-token flow still to be designed | + +## Scores + +| Dimension | Score | Basis | +|---|---|---| +| Money-path integrity | 1/10 | four independent paths create settled money with no rail (#01–#05) | +| Authorization integrity | 2/10 | self-service role elevation, public audit engine, caller-supplied identities | +| Secret/crypto hygiene | 2/10 | empty JWT default, committed webhook secrets, dead validator | +| Error polarity (fail-closed) | 2/10 | risk, valuation, idempotency, revocation and crons all fail open | +| Observability truthfulness | 2/10 | fabricated cargo, security, analytics and enforcement data | +| Deployment coherence | 3/10 | divergent ports and duplicate service families | diff --git a/server/_core/context.ts b/server/_core/context.ts index a765378e..4fb5e221 100644 --- a/server/_core/context.ts +++ b/server/_core/context.ts @@ -1,4 +1,5 @@ import type { CreateExpressContextOptions } from "@trpc/server/adapters/express"; +import { TRPCError } from "@trpc/server"; import type { User } from "../../drizzle/schema"; import { sdk } from "./sdk"; @@ -59,6 +60,14 @@ export async function createContext( } } } catch (error) { + const authHeader = opts.req.headers.authorization as string | undefined; + if (authHeader?.startsWith("Bearer ")) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Bearer token verification failed", + cause: error, + }); + } // Authentication is optional for public procedures. user = null; } diff --git a/server/_core/env.ts b/server/_core/env.ts index f6ba2b02..e61a2d87 100644 --- a/server/_core/env.ts +++ b/server/_core/env.ts @@ -168,6 +168,7 @@ export const ENV = { // ─── Production validation — logs warnings for missing secrets ──────────────── if (ENV.isProduction) { const required: [string, string][] = [ + ["JWT_SECRET", ENV.cookieSecret], ["KEYCLOAK_CLIENT_SECRET", ENV.keycloakClientSecret], ["NIGERIA_ID_CLIENT_ID", ENV.nigeriaIdClientId], ["NIGERIA_ID_CLIENT_SECRET", ENV.nigeriaIdClientSecret], @@ -176,6 +177,6 @@ if (ENV.isProduction) { ["REDIS_PASSWORD", ENV.redisPassword], ]; for (const [name, val] of required) { - if (!val) console.warn(`[ENV] WARNING: ${name} is not set in production`); + if (!val) throw new Error(`[ENV] ${name} is required in production`); } } diff --git a/server/_core/index.ts b/server/_core/index.ts index c5fc5dd1..97f799d7 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -26,6 +26,7 @@ import { sanitizeMiddleware } from "./sanitize"; import { closeKafka } from "./kafka"; import { setupWebSocketServer, broadcastVesselUpdate } from "./wsServer"; import { sdk } from "./sdk"; +import { validateWebhookSecrets } from "./webhookSecretsValidator"; // ── Rate limiting ───────────────────────────────────────────────────────────── // General tRPC API: 200 requests per minute per IP @@ -1181,6 +1182,7 @@ async function runPermifySeedOnStartup() { } async function startServer() { + validateWebhookSecrets(); const app = express(); // Trust the reverse proxy (Manus/nginx) so express-rate-limit reads the correct client IP app.set('trust proxy', 1); @@ -1296,6 +1298,9 @@ async function startServer() { app.use("/api/trpc/tenant", adminOperationRateLimit); app.use("/api/trpc/keycloak", adminOperationRateLimit); + const { registerMojaloopWebhookRoute } = await import("../webhooks/mojaloop"); + registerMojaloopWebhookRoute(app); + // Body parser — 10 MB JSON, 25 MB for URL-encoded (file uploads use multipart) app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ limit: "10mb", extended: true })); @@ -1306,14 +1311,18 @@ async function startServer() { app.post("/api/webhooks/keycloak-event", express.json(), async (req, res) => { try { const secret = process.env.KEYCLOAK_WEBHOOK_SECRET; - if (secret) { - const sig = req.headers["x-keycloak-signature"] as string | undefined; - if (!sig) { res.status(401).json({ error: "Missing signature" }); return; } - const { createHmac } = await import("crypto"); - const hmac = createHmac("sha256", secret); - hmac.update(JSON.stringify(req.body)); - const expected = hmac.digest("hex"); - if (sig !== expected) { res.status(401).json({ error: "Invalid signature" }); return; } + if (!secret) { + res.status(503).json({ error: "Webhook authentication unavailable" }); + return; + } + const sig = req.headers["x-keycloak-signature"] as string | undefined; + if (!sig) { res.status(401).json({ error: "Missing signature" }); return; } + const { createHmac, timingSafeEqual } = await import("crypto"); + const expected = createHmac("sha256", secret).update(JSON.stringify(req.body)).digest("hex"); + const provided = Buffer.from(sig.replace(/^sha256=/, ""), "hex"); + const expectedBuffer = Buffer.from(expected, "hex"); + if (provided.length !== expectedBuffer.length || !timingSafeEqual(provided, expectedBuffer)) { + res.status(401).json({ error: "Invalid signature" }); return; } const event = req.body as { type?: string; realmId?: string; userId?: string; @@ -1324,6 +1333,7 @@ async function startServer() { const actor = event.userId ?? "keycloak-system"; const detail = JSON.stringify({ resourceType: event.resourceType, representation: event.representation }); // Write to auditEvents + let auditPersisted = false; try { const dbModule = await import("../db"); const db = await dbModule.getDb(); @@ -1338,10 +1348,15 @@ async function startServer() { metadata: { actor, detail }, createdAt: event.time ? new Date(event.time) : new Date(), }); + auditPersisted = true; } } catch (dbErr) { console.warn("[Keycloak Webhook] DB write failed:", dbErr); } + if (!auditPersisted) { + res.status(503).json({ error: "Audit persistence unavailable" }); + return; + } // Index in OpenSearch try { const { indexAuditEvent } = await import("./opensearch"); @@ -1426,11 +1441,13 @@ async function startServer() { const { registerE2eTestAuthRoute } = await import("../routes/e2eTestAuth"); registerE2eTestAuthRoute(app); } - // Demo mode auth endpoint — only mounted when DEMO_MODE=true + // Demo mode auth endpoint — never mounted in production // Provides zero-friction demo access without OAuth for all 6 portal roles - if (process.env.DEMO_MODE === "true") { + if (process.env.NODE_ENV !== "production" && process.env.DEMO_MODE === "true") { const { registerDemoAuthRoute } = await import("../routes/demoAuth"); registerDemoAuthRoute(app); + } else if (process.env.NODE_ENV === "production" && process.env.DEMO_MODE === "true") { + console.error("[DemoAuth] DEMO_MODE is disabled in production; demo session route not mounted"); } // SSE endpoint for real-time anomaly alerts (insider threat monitoring) @@ -1449,6 +1466,19 @@ async function startServer() { } // Scheduled Heartbeat handlers — must be before Vite/static fallthrough + app.use("/api/scheduled", async (req, res, next) => { + try { + const user = await sdk.authenticateRequest(req); + if (!user?.isCron || !user.taskUid) { + res.status(403).json({ error: "cron-only endpoint" }); + return; + } + next(); + } catch (error) { + console.error("[Scheduled] Cron authentication failed:", error); + res.status(503).json({ ok: false, error: "Cron authentication unavailable" }); + } + }); { const { bondExpiryDigestHandler } = await import("../scheduled/bondExpiryDigest"); app.post("/api/scheduled/bond-expiry-digest", bondExpiryDigestHandler); diff --git a/server/_core/permify.ts b/server/_core/permify.ts index 9e1c8933..3e165756 100644 --- a/server/_core/permify.ts +++ b/server/_core/permify.ts @@ -51,7 +51,7 @@ export async function can( // In demo mode, bypass Permify and allow all checks const isDemoMode = process.env.DEMO_MODE === "true"; - if (isDemoMode) { + if (isDemoMode && process.env.NODE_ENV !== "production") { return true; } @@ -220,4 +220,3 @@ export async function writeRelationship( ): Promise { await writeTuple(entityType, entityId, relation, subjectType, subjectId); } - diff --git a/server/_core/redisRateLimiter.ts b/server/_core/redisRateLimiter.ts index 9f4a33f7..19cbfd44 100644 --- a/server/_core/redisRateLimiter.ts +++ b/server/_core/redisRateLimiter.ts @@ -191,15 +191,16 @@ export async function revokeSession(sessionId: string): Promise { /** * Checks if a session token has been revoked. * Returns true if the session is blacklisted (should be rejected). + * Throws if Redis cannot be queried so callers cannot accept an unchecked session. */ export async function isSessionRevoked(sessionId: string): Promise { const redis = getRedis(); - if (!redis) return false; // fail-open when Redis is unavailable + if (!redis) throw new Error("Redis unavailable"); try { const val = await redis.get(`revoked:${sessionId}`); return val === "1"; - } catch { - return false; + } catch (error) { + throw new Error("Redis session revocation check failed", { cause: error }); } } diff --git a/server/_core/sdk.ts b/server/_core/sdk.ts index 3c96463f..9600039a 100644 --- a/server/_core/sdk.ts +++ b/server/_core/sdk.ts @@ -1,4 +1,4 @@ -import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; +import { AXIOS_TIMEOUT_MS, COOKIE_NAME, SEVEN_DAYS_MS } from "@shared/const"; import { ForbiddenError } from "@shared/_core/errors"; import axios, { type AxiosInstance } from "axios"; import { parse as parseCookieHeader } from "cookie"; @@ -208,7 +208,7 @@ class SDKServer { options: { expiresInMs?: number } = {} ): Promise { const issuedAt = Date.now(); - const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS; + const expiresInMs = options.expiresInMs ?? SEVEN_DAYS_MS; const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000); const secretKey = this.getSessionSecret(); // R3 FIX: Include a JTI (JWT ID) so individual sessions can be revoked via Redis. @@ -259,7 +259,8 @@ class SDKServer { return null; } } catch { - // Redis unavailable — fail-open, allow session + console.warn("[Auth] Session revocation check unavailable"); + return null; } } diff --git a/server/_core/webhookSecretsValidator.ts b/server/_core/webhookSecretsValidator.ts index 32a5239d..98e344bc 100644 --- a/server/_core/webhookSecretsValidator.ts +++ b/server/_core/webhookSecretsValidator.ts @@ -91,16 +91,16 @@ export function validateWebhookSecrets(): void { } /** - * Returns a safe webhook secret for the given env var. - * In production, throws if the secret is not set or uses a dev default. - * In development, returns the value (even if it's a dev default) with a warning. + * Returns a configured webhook secret for the given env var. + * Throws when no secret is configured or a production secret uses a dev default. + * A development fallback is permitted only when explicitly supplied by a caller. */ -export function getWebhookSecret(envVar: string, devDefault: string): string { +export function getWebhookSecret(envVar: string, devDefault?: string): string { const value = process.env[envVar]; const isProduction = process.env.NODE_ENV === "production"; if (!value || value.trim() === "") { - if (isProduction) { + if (isProduction || devDefault === undefined) { throw new Error(`[WebhookSecrets] ${envVar} must be set in production.`); } console.warn(`[WebhookSecrets] ${envVar} not set, using dev default. DO NOT use in production.`); diff --git a/server/asean.sw.test.ts b/server/asean.sw.test.ts index 1e727af7..e19acad8 100644 --- a/server/asean.sw.test.ts +++ b/server/asean.sw.test.ts @@ -33,25 +33,25 @@ describe("aseanSw.getConnections", () => { expect(typeof caller.aseanSw.getConnections).toBe("function"); }); - it("returns { connections, total, active } object for admin (offline fallback)", async () => { + it("fails closed when ASEAN SW is unavailable for admin", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.getConnections() as any; - expect(result).toBeDefined(); - expect(typeof result).toBe("object"); - expect(Array.isArray(result.connections)).toBe(true); + await expect(caller.aseanSw.getConnections()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("returns { connections, total, active } for user role (protectedProcedure allows all)", async () => { + it("fails closed when ASEAN SW is unavailable for a user", async () => { const caller = appRouter.createCaller(makeCtx({ role: "user" })); - const result = await caller.aseanSw.getConnections() as any; - expect(result).toBeDefined(); - expect(typeof result).toBe("object"); + await expect(caller.aseanSw.getConnections()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("offline fallback has _offline: true when ASEAN SW API is unavailable", async () => { + it("does not fabricate an offline connection result", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.getConnections() as any; - expect(result._offline).toBe(true); + await expect(caller.aseanSw.getConnections()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); it("throws UNAUTHORIZED for unauthenticated requests", async () => { @@ -168,31 +168,32 @@ describe("aseanSw.listMessages", () => { expect(typeof caller.aseanSw.listMessages).toBe("function"); }); - it("returns { messages, total } object for admin (offline fallback)", async () => { + it("fails closed when ASEAN SW is unavailable for admin", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.listMessages({}) as any; - expect(result).toBeDefined(); - expect(typeof result).toBe("object"); - expect(Array.isArray(result.messages)).toBe(true); + await expect(caller.aseanSw.listMessages({})).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("returns { messages, total } for user role (protectedProcedure allows all)", async () => { + it("fails closed when ASEAN SW is unavailable for a user", async () => { const caller = appRouter.createCaller(makeCtx({ role: "user" })); - const result = await caller.aseanSw.listMessages({}) as any; - expect(result).toBeDefined(); - expect(Array.isArray(result.messages)).toBe(true); + await expect(caller.aseanSw.listMessages({})).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("accepts optional destinationCode filter (2-char)", async () => { + it("rejects destinationCode queries when ASEAN SW is unavailable", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.listMessages({ destinationCode: "SG" }) as any; - expect(result).toBeDefined(); + await expect( + caller.aseanSw.listMessages({ destinationCode: "SG" }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); - it("offline fallback has _offline: true in test env", async () => { + it("does not fabricate an offline message result", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.listMessages({}) as any; - expect(result._offline).toBe(true); + await expect(caller.aseanSw.listMessages({})).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); it("throws UNAUTHORIZED for unauthenticated requests", async () => { @@ -208,25 +209,25 @@ describe("aseanSw.listInboundMessages", () => { expect(typeof caller.aseanSw.listInboundMessages).toBe("function"); }); - it("returns { messages, total, unread } object for admin (offline fallback)", async () => { + it("fails closed when ASEAN SW is unavailable for admin", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.listInboundMessages({}) as any; - expect(result).toBeDefined(); - expect(typeof result).toBe("object"); - expect(Array.isArray(result.messages)).toBe(true); + await expect(caller.aseanSw.listInboundMessages({})).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("returns { messages, total, unread } for user role (protectedProcedure allows all)", async () => { + it("fails closed when ASEAN SW is unavailable for a user", async () => { const caller = appRouter.createCaller(makeCtx({ role: "user" })); - const result = await caller.aseanSw.listInboundMessages({}) as any; - expect(result).toBeDefined(); - expect(Array.isArray(result.messages)).toBe(true); + await expect(caller.aseanSw.listInboundMessages({})).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("accepts optional sourceCode filter (2-char)", async () => { + it("rejects sourceCode queries when ASEAN SW is unavailable", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.listInboundMessages({ sourceCode: "SG" }) as any; - expect(result).toBeDefined(); + await expect( + caller.aseanSw.listInboundMessages({ sourceCode: "SG" }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); it("throws UNAUTHORIZED for unauthenticated requests", async () => { @@ -286,34 +287,32 @@ describe("aseanSw.getConnectivityStatus", () => { expect(typeof caller.aseanSw.getConnectivityStatus).toBe("function"); }); - it("returns { members, checkedAt } object with offline fallback for admin", async () => { + it("fails closed when ASEAN SW is unavailable for admin", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.getConnectivityStatus() as any; - expect(result).toBeDefined(); - expect(Array.isArray(result.members)).toBe(true); - expect(typeof result.checkedAt).toBe("string"); + await expect(caller.aseanSw.getConnectivityStatus()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("returns { members, checkedAt } for user role (protectedProcedure allows all)", async () => { + it("fails closed when ASEAN SW is unavailable for a user", async () => { const caller = appRouter.createCaller(makeCtx({ role: "user" })); - const result = await caller.aseanSw.getConnectivityStatus() as any; - expect(result).toBeDefined(); - expect(Array.isArray(result.members)).toBe(true); + await expect(caller.aseanSw.getConnectivityStatus()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("offline fallback has _offline: true in test env", async () => { + it("does not fabricate offline connectivity data", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.getConnectivityStatus() as any; - expect(result._offline).toBe(true); + await expect(caller.aseanSw.getConnectivityStatus()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("each member has score and tier fields", async () => { + it("does not fabricate member scores or tiers", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.getConnectivityStatus() as any; - for (const member of result.members) { - expect(typeof member.score).toBe("number"); - expect(typeof member.tier).toBe("string"); - } + await expect(caller.aseanSw.getConnectivityStatus()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); it("throws UNAUTHORIZED for unauthenticated requests", async () => { @@ -372,25 +371,25 @@ describe("aseanSw.getStats", () => { expect(typeof caller.aseanSw.getStats).toBe("function"); }); - it("returns { total, by_status } object with offline fallback for admin", async () => { + it("fails closed when ASEAN SW is unavailable for admin", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.getStats() as any; - expect(result).toBeDefined(); - expect(typeof result.total).toBe("number"); - expect(typeof result.by_status).toBe("object"); + await expect(caller.aseanSw.getStats()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("returns { total, by_status } for user role (protectedProcedure allows all)", async () => { + it("fails closed when ASEAN SW is unavailable for a user", async () => { const caller = appRouter.createCaller(makeCtx({ role: "user" })); - const result = await caller.aseanSw.getStats() as any; - expect(result).toBeDefined(); - expect(typeof result.total).toBe("number"); + await expect(caller.aseanSw.getStats()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("offline fallback has _offline: true in test env", async () => { + it("does not fabricate offline statistics", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); - const result = await caller.aseanSw.getStats() as any; - expect(result._offline).toBe(true); + await expect(caller.aseanSw.getStats()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); it("throws UNAUTHORIZED for unauthenticated requests", async () => { diff --git a/server/cen.test.ts b/server/cen.test.ts index f1591060..7369feb9 100644 --- a/server/cen.test.ts +++ b/server/cen.test.ts @@ -41,34 +41,32 @@ function makeCtx(overrides: Partial = {}): TrpcContext { // ─── getPartners ────────────────────────────────────────────────────────────── describe("cen.getPartners", () => { - it("returns fallback {partners, total} when cen-service is unavailable", async () => { + it("fails closed when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.getPartners({}); - expect(result).toBeDefined(); - expect(result).toHaveProperty("partners"); - expect(result).toHaveProperty("total"); - expect(Array.isArray(result.partners)).toBe(true); + await expect(caller.cen.getPartners({})).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("accepts optional region filter", async () => { + it("rejects optional region queries when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.getPartners({ region: "AFRICA" }); - expect(result).toBeDefined(); - expect(result).toHaveProperty("partners"); + await expect( + caller.cen.getPartners({ region: "AFRICA" }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); - it("accepts optional activeOnly filter", async () => { + it("rejects optional activeOnly queries when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.getPartners({ activeOnly: true }); - expect(result).toBeDefined(); - expect(result).toHaveProperty("partners"); + await expect( + caller.cen.getPartners({ activeOnly: true }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); - it("accepts both region and activeOnly filters simultaneously", async () => { + it("rejects filtered queries when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.getPartners({ region: "ASIA", activeOnly: true }); - expect(result).toBeDefined(); - expect(result).toHaveProperty("partners"); + await expect( + caller.cen.getPartners({ region: "ASIA", activeOnly: true }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); it("throws UNAUTHORIZED for unauthenticated caller", async () => { @@ -209,47 +207,50 @@ describe("cen.receiveAlert", () => { // ─── listAlerts ─────────────────────────────────────────────────────────────── describe("cen.listAlerts", () => { - it("returns fallback {alerts, total} when cen-service is unavailable", async () => { + it("fails closed when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.listAlerts({}); - expect(result).toBeDefined(); - expect(result).toHaveProperty("alerts"); - expect(result).toHaveProperty("total"); - expect(Array.isArray(result.alerts)).toBe(true); + await expect(caller.cen.listAlerts({})).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); - it("accepts direction filter OUTBOUND", async () => { + it("rejects direction queries when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.listAlerts({ direction: "OUTBOUND" }); - expect(result).toHaveProperty("alerts"); + await expect( + caller.cen.listAlerts({ direction: "OUTBOUND" }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); - it("accepts direction filter INBOUND", async () => { + it("rejects inbound direction queries when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.listAlerts({ direction: "INBOUND" }); - expect(result).toHaveProperty("alerts"); + await expect( + caller.cen.listAlerts({ direction: "INBOUND" }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); - it("accepts priority filter HIGH", async () => { + it("rejects priority queries when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.listAlerts({ priority: "HIGH" }); - expect(result).toHaveProperty("alerts"); + await expect( + caller.cen.listAlerts({ priority: "HIGH" }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); - it("accepts alertType filter SEIZURE", async () => { + it("rejects alert-type queries when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.listAlerts({ alertType: "SEIZURE" }); - expect(result).toHaveProperty("alerts"); + await expect( + caller.cen.listAlerts({ alertType: "SEIZURE" }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); - it("accepts combined filters (direction + priority + alertType)", async () => { + it("rejects combined filtered queries when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.listAlerts({ - direction: "INBOUND", - priority: "HIGH", - alertType: "RISK_PROFILE", - }); - expect(result).toHaveProperty("alerts"); + await expect( + caller.cen.listAlerts({ + direction: "INBOUND", + priority: "HIGH", + alertType: "RISK_PROFILE", + }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); it("rejects invalid direction enum value", async () => { @@ -267,22 +268,18 @@ describe("cen.listAlerts", () => { // ─── correlateAlert ─────────────────────────────────────────────────────────── describe("cen.correlateAlert", () => { - it("returns fallback correlation object when cen-service is unavailable", async () => { + it("fails closed when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.correlateAlert({ alertId: "ALERT-001" }); - expect(result).toBeDefined(); - expect(result).toHaveProperty("alertId"); - expect(result).toHaveProperty("matchedAlerts"); - expect(result).toHaveProperty("correlationScore"); - expect(result).toHaveProperty("reason"); - expect(result.alertId).toBe("ALERT-001"); - expect(Array.isArray(result.matchedAlerts)).toBe(true); + await expect( + caller.cen.correlateAlert({ alertId: "ALERT-001" }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); - it("preserves alertId in fallback response", async () => { + it("does not fabricate an alert correlation when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.correlateAlert({ alertId: "CEN-2026-XYZ" }); - expect(result.alertId).toBe("CEN-2026-XYZ"); + await expect( + caller.cen.correlateAlert({ alertId: "CEN-2026-XYZ" }) + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); it("throws UNAUTHORIZED for unauthenticated caller", async () => { @@ -306,29 +303,18 @@ describe("cen.acknowledgeAlert", () => { // ─── getStats ───────────────────────────────────────────────────────────────── describe("cen.getStats", () => { - it("returns fallback stats object with all required fields when cen-service is unavailable", async () => { + it("fails closed when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.getStats(); - expect(result).toBeDefined(); - expect(result).toHaveProperty("total"); - expect(result).toHaveProperty("outbound"); - expect(result).toHaveProperty("inbound"); - expect(result).toHaveProperty("high"); - expect(result).toHaveProperty("medium"); - expect(result).toHaveProperty("low"); - expect(result).toHaveProperty("active"); - expect(result).toHaveProperty("acknowledged"); - expect(result).toHaveProperty("activePartners"); - expect(result).toHaveProperty("totalPartners"); - }); - - it("fallback stats have numeric values", async () => { + await expect(caller.cen.getStats()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); + }); + + it("does not fabricate numeric stats when cen-service is unavailable", async () => { const caller = appRouter.createCaller(makeCtx()); - const result = await caller.cen.getStats(); - expect(typeof result.total).toBe("number"); - expect(typeof result.outbound).toBe("number"); - expect(typeof result.inbound).toBe("number"); - expect(typeof result.high).toBe("number"); + await expect(caller.cen.getStats()).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + }); }); it("throws UNAUTHORIZED for unauthenticated caller", async () => { diff --git a/server/fund-flow.test.ts b/server/fund-flow.test.ts index 11b524cd..2f6dfdba 100644 --- a/server/fund-flow.test.ts +++ b/server/fund-flow.test.ts @@ -32,6 +32,13 @@ vi.mock("redis", () => ({ const mockFetch = vi.fn(); global.fetch = mockFetch; +beforeEach(() => { + mockFetch.mockImplementation(async () => ({ + ok: true, + json: async () => ({ can: "CHECK_RESULT_ALLOWED" }), + })); +}); + // Mock getDb const mockDbSelect = vi.fn(); const mockDbInsert = vi.fn(); @@ -65,6 +72,10 @@ function makeTraderCtx(overrides: Record = {}) { } function mockTemporalSuccess(workflowId = "wf-test-001", runId = "run-001") { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ can: "CHECK_RESULT_ALLOWED" }), + }); mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ workflowId, runId }), @@ -72,6 +83,10 @@ function mockTemporalSuccess(workflowId = "wf-test-001", runId = "run-001") { } function mockTemporalFailure(status = 500, body = "Internal Server Error") { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ can: "CHECK_RESULT_ALLOWED" }), + }); mockFetch.mockResolvedValueOnce({ ok: false, text: async () => body, @@ -183,7 +198,10 @@ describe("Scenario 1: Import Duty Collection", () => { const result = await caller.collectImportDuty({ declarationId: 42 }); expect(result.idempotent).toBe(true); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringContaining("/workflows/trigger"), + expect.anything(), + ); }); it("throws NOT_FOUND for missing declaration", async () => { @@ -212,7 +230,7 @@ describe("Scenario 1: Import Duty Collection", () => { const { fundFlowRouter } = await import("./routers/fund-flow"); const caller = fundFlowRouter.createCaller(makeTraderCtx() as never); await expect(caller.collectImportDuty({ declarationId: 42 })) - .rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR" }); + .rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); }); @@ -372,7 +390,10 @@ describe("Scenario 5: Bond Guarantee Lodgement", () => { }); expect(result.idempotent).toBe(true); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringContaining("/workflows/trigger"), + expect.anything(), + ); }); }); @@ -790,7 +811,7 @@ describe("Scenario 20: Trader Account Provisioning", () => { mockTemporalSuccess("wf-provision-001"); const { fundFlowRouter } = await import("./routers/fund-flow"); - const caller = fundFlowRouter.createCaller(makeTraderCtx() as never); + const caller = fundFlowRouter.createCaller(makeAdminCtx() as never); const result = await caller.provisionTraderAccount({ currency: "NGN" }); expect(result.workflowId).toBe("wf-provision-001"); expect(result.idempotent).toBe(false); @@ -800,7 +821,7 @@ describe("Scenario 20: Trader Account Provisioning", () => { mockRedisDuplicate(); const { fundFlowRouter } = await import("./routers/fund-flow"); - const caller = fundFlowRouter.createCaller(makeTraderCtx() as never); + const caller = fundFlowRouter.createCaller(makeAdminCtx() as never); const result = await caller.provisionTraderAccount({ currency: "NGN" }); expect(result.idempotent).toBe(true); expect(result.message).toContain("already provisioned"); @@ -811,7 +832,7 @@ describe("Scenario 20: Trader Account Provisioning", () => { mockTemporalSuccess("wf-provision-002"); const { fundFlowRouter } = await import("./routers/fund-flow"); - const caller = fundFlowRouter.createCaller(makeTraderCtx() as never); + const caller = fundFlowRouter.createCaller(makeAdminCtx() as never); const result = await caller.provisionTraderAccount({}); expect(result.workflowId).toBe("wf-provision-002"); }); @@ -823,6 +844,10 @@ describe("Cross-cutting: getWorkflowStatus", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns workflow status from Temporal service", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ can: "CHECK_RESULT_ALLOWED" }), + }); mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ status: "COMPLETED", result: { tigerBeetleTxId: "tb-999" } }), @@ -836,6 +861,10 @@ describe("Cross-cutting: getWorkflowStatus", () => { }); it("throws NOT_FOUND for unknown workflow", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ can: "CHECK_RESULT_ALLOWED" }), + }); mockFetch.mockResolvedValueOnce({ ok: false, json: async () => ({ error: "Not found" }), @@ -860,7 +889,10 @@ describe("Atomicity Guarantees", () => { const caller = fundFlowRouter.createCaller(makeTraderCtx() as never); await caller.collectExportLevy({ declarationId: 1, levyAmountMinor: 1000 }); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringContaining("/workflows/trigger"), + expect.anything(), + ); }); it("does NOT write to DB if Temporal workflow trigger fails", async () => { diff --git a/server/paymentWorker.ts b/server/paymentWorker.ts index b3ba106d..15fe1c27 100644 --- a/server/paymentWorker.ts +++ b/server/paymentWorker.ts @@ -8,9 +8,9 @@ * Architecture: * 1. Poll payment_queue WHERE status='queued' AND next_retry_at <= NOW() * 2. Claim each row by setting status='processing' (optimistic lock via UPDATE … WHERE status='queued') - * 3. Call the Mojaloop ILP switch (or simulate in dev mode) + * 3. Call the Mojaloop ILP switch * 4. On success: status='committed', update balance mirror, update mojaloop_transactions - * 5. On failure: increment attempt_count, compute exp back-off, status='failed' or 'dead_letter' + * 5. On failure: increment attempt_count, compute exp back-off, requeue or dead-letter * * Exponential back-off: delay = min(2^attempt × 1_000ms, 3_600_000ms) * Dead-letter threshold: attempt_count >= max_attempts (default 5) @@ -87,17 +87,7 @@ async function callMojaloopTransfer(item: typeof paymentQueue.$inferSelect): Pro }> { const available = await mojaloopAvailable(); - if (!available) { - // Simulation mode: deterministic success after attempt 0 - const simulatedSuccess = item.attemptCount === 0 || Math.random() > 0.1; - if (simulatedSuccess) { - return { - success: true, - fulfilment: deriveIlpFulfilment(item.transferId), - }; - } - return { success: false, error: "Simulated transient failure" }; - } + if (!available) return { success: false, error: "Mojaloop switch unavailable" }; const condition = deriveIlpCondition(item.transferId); const fulfilment = deriveIlpFulfilment(item.transferId); @@ -258,7 +248,7 @@ async function processItem( await db .update(paymentQueue) .set({ - status: isDead ? "dead_letter" : "failed", + status: isDead ? "dead_letter" : "queued", attemptCount: newAttemptCount, lastError: error ?? "Unknown error", nextRetryAt, diff --git a/server/payments.test.ts b/server/payments.test.ts index b63fc3fc..74b6d745 100644 --- a/server/payments.test.ts +++ b/server/payments.test.ts @@ -138,13 +138,11 @@ describe("payments router", () => { ).rejects.toThrow(); }); - it("initiates payment for a valid declaration", async () => { - const result = await traderCaller.payments.initiate({ + it("fails closed when the payment queue is unavailable", async () => { + await expect(traderCaller.payments.initiate({ declarationId: 1, paymentMethod: "mobile_money", - }); - expect(result).toHaveProperty("id"); - expect(result).toHaveProperty("status", "pending"); + })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); }); }); diff --git a/server/routers/aseanSw.ts b/server/routers/aseanSw.ts index 9608bc27..30bd220e 100644 --- a/server/routers/aseanSw.ts +++ b/server/routers/aseanSw.ts @@ -12,15 +12,20 @@ import { protectedProcedure, router } from "../_core/trpc"; const ASEAN_SVC = process.env.ASEAN_SW_SERVICE_URL ?? "http://localhost:8096"; async function aseanFetch(path: string, opts?: RequestInit) { - const res = await fetch(`${ASEAN_SVC}${path}`, { - ...opts, - headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) }, - }); - if (!res.ok) { - const body = await res.text(); - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `asean-sw-service error: ${body}` }); + try { + const res = await fetch(`${ASEAN_SVC}${path}`, { + ...opts, + headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) }, + }); + if (!res.ok) { + const body = await res.text(); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: `asean-sw-service error: ${body}` }); + } + return res.json(); + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "ASEAN SW service unavailable" }); } - return res.json(); } // ASEAN member states @@ -55,11 +60,7 @@ export function classifyConnectivity(score: number): "excellent" | "good" | "deg export const aseanSwRouter = router({ /** Get all ASEAN member state bilateral connections and their status */ getConnections: protectedProcedure.query(async () => { - try { - return await aseanFetch("/api/asean/connections"); - } catch { - return { connections: [], total: 0, active: 0, _offline: true }; - } + return aseanFetch("/api/asean/connections"); }), /** Ping a specific ASEAN member state gateway to test connectivity */ @@ -118,11 +119,7 @@ export const aseanSwRouter = router({ .input(z.object({ destinationCode: z.string().length(2).optional() }).optional()) .query(async ({ input }) => { const qs = input?.destinationCode ? `?destination=${input.destinationCode.toUpperCase()}` : ""; - try { - return await aseanFetch(`/api/asean/messages${qs}`); - } catch { - return { messages: [], total: 0, _offline: true }; - } + return aseanFetch(`/api/asean/messages${qs}`); }), /** List inbound G2G messages received from member states */ @@ -130,27 +127,7 @@ export const aseanSwRouter = router({ .input(z.object({ sourceCode: z.string().length(2).optional() }).optional()) .query(async ({ input }) => { const qs = input?.sourceCode ? `?source=${input.sourceCode.toUpperCase()}` : ""; - try { - return await aseanFetch(`/api/asean/messages/inbound${qs}`); - } catch { - const messageTypes = ["ACDD", "SSTC", "ATIGA"] as const; - const sources = ["SG", "MY", "TH", "ID", "VN"]; - const statuses = ["pending_ack", "accepted", "rejected"]; - return { - messages: Array.from({ length: 8 }, (_, i) => ({ - id: `inbound-${i + 1}`, - message_ref: `INBOUND-${1000 + i}`, - source_code: sources[i % sources.length], - message_type: messageTypes[i % messageTypes.length], - ucr: `UCR-${2000 + i}`, - status: statuses[i % statuses.length], - received_at: new Date(Date.now() - i * 3600_000).toISOString(), - ack_reference: statuses[i % statuses.length] !== "pending_ack" ? `ACK-${3000 + i}` : undefined, - })), - total: 8, - _offline: true, - }; - } + return aseanFetch(`/api/asean/messages/inbound${qs}`); }), /** Acknowledge an inbound G2G message from a member state */ @@ -161,50 +138,22 @@ export const aseanSwRouter = router({ reason: z.string().max(500).optional(), })) .mutation(async ({ input }) => { - try { - return await aseanFetch(`/api/asean/messages/${input.messageId}/ack`, { - method: "POST", - body: JSON.stringify({ status: input.status, reason: input.reason ?? "" }), - }); - } catch { - return { - messageId: input.messageId, - status: input.status, - ackReference: `ACK-${Date.now()}`, - acknowledgedAt: new Date().toISOString(), - _offline: true, - }; - } + return aseanFetch(`/api/asean/messages/${input.messageId}/ack`, { + method: "POST", + body: JSON.stringify({ status: input.status, reason: input.reason ?? "" }), + }); }), /** Retry a failed outbound G2G message */ retryMessage: protectedProcedure .input(z.object({ messageId: z.string().min(3) })) .mutation(async ({ input }) => { - try { - return await aseanFetch(`/api/asean/messages/${input.messageId}/retry`, { - method: "POST", - }); - } catch { - return { messageId: input.messageId, status: "queued", retryAt: new Date().toISOString(), _offline: true }; - } + return aseanFetch(`/api/asean/messages/${input.messageId}/retry`, { method: "POST" }); }), /** Get detailed connectivity metrics for all 10 ASEAN member states */ getConnectivityStatus: protectedProcedure.query(async () => { - try { - return await aseanFetch("/api/asean/connectivity"); - } catch { - return { - members: ASEAN_MEMBERS.map((m) => ({ - ...m, - score: computeConnectivityScore(m.uptime, m.latency_ms), - tier: classifyConnectivity(computeConnectivityScore(m.uptime, m.latency_ms)), - })), - checkedAt: new Date().toISOString(), - _offline: true, - }; - } + return aseanFetch("/api/asean/connectivity"); }), /** Handle inbound acknowledgement from a member state gateway */ @@ -229,11 +178,7 @@ export const aseanSwRouter = router({ /** Get message statistics for the admin dashboard */ getStats: protectedProcedure.query(async () => { - try { - return await aseanFetch("/api/asean/stats"); - } catch { - return { total: 0, by_status: {}, _offline: true }; - } + return aseanFetch("/api/asean/stats"); }), /** @@ -257,16 +202,14 @@ export const aseanSwRouter = router({ const results = await Promise.allSettled( MEMBER_ENDPOINTS.map(async (m) => { if (!m.url) { - // No endpoint configured — use static data from ASEAN_MEMBERS - const staticMember = ASEAN_MEMBERS.find((s) => s.code === m.code); return { code: m.code, name: m.name, - status: (staticMember?.status === "active" ? "online" : "maintenance") as string, - latencyMs: staticMember?.latency_ms ?? null, + status: "unavailable", + latencyMs: null as number | null, httpStatus: null as number | null, checkedAt: new Date().toISOString(), - source: "static", + source: "unavailable", }; } const start = Date.now(); diff --git a/server/routers/auditEngine.ts b/server/routers/auditEngine.ts index f8c417a0..66e1bb1e 100644 --- a/server/routers/auditEngine.ts +++ b/server/routers/auditEngine.ts @@ -4,7 +4,8 @@ * No in-memory stores. */ import { z } from "zod"; -import { publicProcedure, router } from "../_core/trpc"; +import { protectedProcedure, router } from "../_core/trpc"; +import { TRPCError } from "@trpc/server"; import { getDb } from "../db"; import { auditTasks, auditFindings, @@ -26,6 +27,11 @@ export type FindingType = | "duty_evasion" | "no_finding"; const SENSITIVE_CHAPTERS = new Set(["24", "27", "36", "71", "87", "88", "93"]); +const AUDIT_ROLES = new Set(["admin", "customs_officer", "oga_officer", "inspector", "finance", "auditor"]); + +function assertAuditOfficer(role: string): void { + if (!AUDIT_ROLES.has(role)) throw new TRPCError({ code: "FORBIDDEN", message: "Audit officer access required" }); +} export function selectForAudit(params: { riskScore: number; declaredValueUsd: number; @@ -47,17 +53,18 @@ export function calculateDutyDiscrepancy(findings: { findingType: string; amount // ─── Router ────────────────────────────────────────────────────────────────── export const auditEngineRouter = router({ - getAuditTasks: publicProcedure + getAuditTasks: protectedProcedure .input(z.object({ status: z.enum(["pending","assigned","in_progress","findings_submitted","closed","appealed"]).optional(), assignedOfficerId: z.string().optional(), limit: z.number().int().min(1).max(100).default(50), offset: z.number().int().min(0).default(0), })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = await getDb(); - if (!db) return { total: 0, tasks: [] }; + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); const conditions: any[] = []; + if (!AUDIT_ROLES.has(ctx.user.role)) conditions.push(eq(auditTasks.assignedOfficerId, String(ctx.user.id))); if (input.status) conditions.push(eq(auditTasks.status, input.status)); if (input.assignedOfficerId) conditions.push(eq(auditTasks.assignedOfficerId, input.assignedOfficerId)); const [tasks, countResult] = await Promise.all([ @@ -86,29 +93,33 @@ export const auditEngineRouter = router({ }; }), - getAuditTask: publicProcedure + getAuditTask: protectedProcedure .input(z.object({ auditId: z.string() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const db = await getDb(); - if (!db) throw new Error("Database unavailable"); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); const [task] = await db.select().from(auditTasks).where(eq(auditTasks.id, input.auditId)); if (!task) throw new Error(`Audit task ${input.auditId} not found`); + if (!AUDIT_ROLES.has(ctx.user.role) && task.assignedOfficerId !== String(ctx.user.id)) { + throw new TRPCError({ code: "FORBIDDEN" }); + } const findings = await db.select().from(auditFindings).where(eq(auditFindings.auditTaskId, input.auditId)); return { ...task, findings, declaredValueUsd: Number(task.declaredValueUsd), dutyPaidUsd: Number(task.dutyPaidUsd), riskScore: Number(task.riskScore), dutyDiscrepancyUsd: Number(task.dutyDiscrepancyUsd ?? 0) }; }), - createAuditTask: publicProcedure + createAuditTask: protectedProcedure .input(z.object({ declarationId: z.string(), declarantName: z.string(), hsCode: z.string().optional(), declaredValueUsd: z.number(), dutyPaidUsd: z.number(), selectionReason: z.enum(["risk_score_high","random_sample","trader_tier_review","value_threshold","hs_chapter_sensitive","repeat_offender","post_green_lane"]), riskScore: z.number().min(0).max(100), dueAt: z.string().optional(), })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + assertAuditOfficer(ctx.user.role); const db = await getDb(); - if (!db) throw new Error("Database unavailable"); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); const id = `audit-${crypto.randomBytes(6).toString("hex")}`; const dueAt = input.dueAt ? new Date(input.dueAt) : new Date(Date.now() + 14 * 24 * 3600_000); const [task] = await db.insert(auditTasks).values({ @@ -120,11 +131,12 @@ export const auditEngineRouter = router({ return { ...task, findings: [] }; }), - assignAuditTask: publicProcedure + assignAuditTask: protectedProcedure .input(z.object({ auditId: z.string(), officerId: z.string(), officerName: z.string() })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + assertAuditOfficer(ctx.user.role); const db = await getDb(); - if (!db) throw new Error("Database unavailable"); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); const [updated] = await db.update(auditTasks) .set({ assignedOfficerId: input.officerId, assignedOfficerName: input.officerName, status: "assigned" }) .where(eq(auditTasks.id, input.auditId)).returning(); @@ -132,7 +144,7 @@ export const auditEngineRouter = router({ return updated; }), - submitFindings: publicProcedure + submitFindings: protectedProcedure .input(z.object({ auditId: z.string(), findings: z.array(z.object({ @@ -140,9 +152,10 @@ export const auditEngineRouter = router({ description: z.string(), amountUsd: z.number().min(0).default(0), evidenceUrl: z.string().default(""), })), })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + assertAuditOfficer(ctx.user.role); const db = await getDb(); - if (!db) throw new Error("Database unavailable"); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); await db.delete(auditFindings).where(eq(auditFindings.auditTaskId, input.auditId)); const newFindings = input.findings.map((f) => ({ id: `finding-${crypto.randomBytes(4).toString("hex")}`, @@ -157,11 +170,12 @@ export const auditEngineRouter = router({ return { ...updated, findings: newFindings }; }), - closeAudit: publicProcedure + closeAudit: protectedProcedure .input(z.object({ auditId: z.string() })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + assertAuditOfficer(ctx.user.role); const db = await getDb(); - if (!db) throw new Error("Database unavailable"); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); const [updated] = await db.update(auditTasks) .set({ status: "closed", closedAt: new Date() }) .where(eq(auditTasks.id, input.auditId)).returning(); @@ -169,11 +183,12 @@ export const auditEngineRouter = router({ return updated; }), - appealAudit: publicProcedure + appealAudit: protectedProcedure .input(z.object({ auditId: z.string(), appealNotes: z.string() })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + assertAuditOfficer(ctx.user.role); const db = await getDb(); - if (!db) throw new Error("Database unavailable"); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); const [updated] = await db.update(auditTasks) .set({ status: "appealed", appealNotes: input.appealNotes }) .where(eq(auditTasks.id, input.auditId)).returning(); @@ -181,11 +196,12 @@ export const auditEngineRouter = router({ return updated; }), - getDutyDiscrepancyReport: publicProcedure + getDutyDiscrepancyReport: protectedProcedure .input(z.object({ fromDate: z.string().optional(), toDate: z.string().optional() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + assertAuditOfficer(ctx.user.role); const db = await getDb(); - if (!db) return { totalAudited: 0, withFindings: 0, totalDiscrepancyUsd: 0, averageDiscrepancyUsd: 0, byFindingType: {}, topDiscrepancies: [] }; + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); const conditions: any[] = [inArray(auditTasks.status, ["closed","findings_submitted"])]; if (input.fromDate) conditions.push(gte(auditTasks.createdAt, new Date(input.fromDate))); if (input.toDate) conditions.push(lte(auditTasks.createdAt, new Date(input.toDate))); @@ -209,9 +225,10 @@ export const auditEngineRouter = router({ }; }), - getAuditStats: publicProcedure.query(async () => { + getAuditStats: protectedProcedure.query(async ({ ctx }) => { + assertAuditOfficer(ctx.user.role); const db = await getDb(); - if (!db) return { total: 0, byStatus: {}, byReason: {}, totalDiscrepancyUsd: 0, overdueTasks: 0 }; + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); const tasks = await db.select().from(auditTasks); const byStatus: Record = {}; const byReason: Record = {}; @@ -227,7 +244,7 @@ export const auditEngineRouter = router({ return { total: tasks.length, byStatus, byReason, totalDiscrepancyUsd: totalDiscrepancy, overdueTasks }; }), - runAuditSelection: publicProcedure + runAuditSelection: protectedProcedure .input(z.object({ declarations: z.array(z.object({ declarationId: z.string(), declarantName: z.string(), hsCode: z.string(), @@ -235,9 +252,10 @@ export const auditEngineRouter = router({ traderTier: z.enum(["new","standard","aeo"]), laneAssigned: z.enum(["GREEN","YELLOW","RED"]), })), })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + assertAuditOfficer(ctx.user.role); const db = await getDb(); - if (!db) throw new Error("Database unavailable"); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Audit database unavailable" }); const selected = []; for (const decl of input.declarations) { const seed = Math.random(); diff --git a/server/routers/batchPayments.ts b/server/routers/batchPayments.ts index e9eada75..e84c71b2 100644 --- a/server/routers/batchPayments.ts +++ b/server/routers/batchPayments.ts @@ -104,7 +104,7 @@ export const batchPaymentsRouter = router({ return { retried: deadItems.length, transferIds: deadItems.map((i) => i.transferId) }; }), - getAccountBalance: protectedProcedure + getAccountBalance: adminProcedure .input(z.object({ accountId: z.string().min(1) })) .query(async ({ input }) => { const db = await getDb(); @@ -125,7 +125,7 @@ export const batchPaymentsRouter = router({ }; }), - listQueue: protectedProcedure + listQueue: adminProcedure .input(z.object({ status: z.enum(["queued", "processing", "committed", "failed", "dead_letter", "all"]).default("all"), page: z.number().int().min(1).default(1), @@ -149,7 +149,7 @@ export const batchPaymentsRouter = router({ }; }), - listArchivalJobs: protectedProcedure + listArchivalJobs: adminProcedure .input(z.object({ tier: z.enum(["hot", "warm", "cold", "all"]).default("all"), page: z.number().int().min(1).default(1), @@ -177,7 +177,7 @@ export const batchPaymentsRouter = router({ * List all payment accounts with live net balance. * Used by the Balance Accounts dashboard page. */ - listAllAccounts: protectedProcedure + listAllAccounts: adminProcedure .input(z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(50), diff --git a/server/routers/cen.ts b/server/routers/cen.ts index 9121ee04..9be5573d 100644 --- a/server/routers/cen.ts +++ b/server/routers/cen.ts @@ -14,17 +14,16 @@ async function cenFetch(path: string, options?: RequestInit) { ...options, headers: { "Content-Type": "application/json", ...(options?.headers ?? {}) }, }); - const text = await res.text(); + const text = await res.text(); if (!res.ok) { let msg = text; try { msg = JSON.parse(text).error ?? text; } catch {} - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: msg }); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: msg }); } return JSON.parse(text); } catch (err) { if (err instanceof TRPCError) throw err; - // Service unavailable — return graceful fallback - return null; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "CEN service unavailable" }); } } @@ -42,8 +41,7 @@ export const cenRouter = router({ const params = new URLSearchParams(); if (input.region) params.set("region", input.region); if (input.activeOnly) params.set("activeOnly", "true"); - const data = await cenFetch(`/partners?${params}`); - return data ?? { partners: [], total: 0 }; + return cenFetch(`/partners?${params}`); }), // Send a risk alert to a partner customs administration @@ -102,16 +100,14 @@ export const cenRouter = router({ if (input.direction) params.set("direction", input.direction); if (input.priority) params.set("priority", input.priority); if (input.alertType) params.set("alertType", input.alertType); - const data = await cenFetch(`/alerts?${params}`); - return data ?? { alerts: [], total: 0 }; + return cenFetch(`/alerts?${params}`); }), // Correlate an alert with existing alerts correlateAlert: protectedProcedure .input(z.object({ alertId: z.string() })) .query(async ({ input }) => { - const data = await cenFetch(`/alerts/${input.alertId}/correlate`); - return data ?? { alertId: input.alertId, matchedAlerts: [], correlationScore: 0, reason: "Service unavailable" }; + return cenFetch(`/alerts/${input.alertId}/correlate`); }), // Acknowledge an inbound alert @@ -126,13 +122,7 @@ export const cenRouter = router({ // Get CEN statistics getStats: protectedProcedure .query(async () => { - const data = await cenFetch("/stats"); - return data ?? { - total: 0, outbound: 0, inbound: 0, - high: 0, medium: 0, low: 0, - active: 0, acknowledged: 0, - activePartners: 0, totalPartners: 0, - }; + return cenFetch("/stats"); }), /** @@ -148,20 +138,10 @@ export const cenRouter = router({ originCountry: z.string().length(2).optional(), })) .query(async ({ input }) => { - const data = await cenFetch("/enrich/declaration", { + return cenFetch("/enrich/declaration", { method: "POST", body: JSON.stringify(input), }); - if (data) return data; - // Offline fallback: return empty enrichment - return { - declarationId: input.declarationId, - matchedAlerts: [] as string[], - riskFlags: [] as string[], - enrichmentScore: 0, - source: "offline", - enrichedAt: new Date().toISOString(), - }; }), /** @@ -176,17 +156,7 @@ export const cenRouter = router({ .query(async ({ input }) => { const params = new URLSearchParams({ traderRef: input.traderRef }); if (input.includeHistory) params.set("includeHistory", "true"); - const data = await cenFetch(`/risk/trader?${params}`); - if (data) return data; - return { - traderRef: input.traderRef, - riskLevel: "UNKNOWN" as string, - alertCount: 0, - highPriorityCount: 0, - lastAlertAt: null as string | null, - history: [] as unknown[], - source: "offline", - }; + return cenFetch(`/risk/trader?${params}`); }), /** @@ -204,23 +174,9 @@ export const cenRouter = router({ })).min(1).max(50), })) .mutation(async ({ input }) => { - const data = await cenFetch("/enrich/bulk", { + return cenFetch("/enrich/bulk", { method: "POST", body: JSON.stringify({ declarations: input.declarations }), }); - if (data) return data; - // Offline fallback: return empty enrichment for each declaration - return { - results: input.declarations.map((d) => ({ - declarationId: d.declarationId, - matchedAlerts: [] as string[], - riskFlags: [] as string[], - enrichmentScore: 0, - source: "offline", - enrichedAt: new Date().toISOString(), - })), - processedAt: new Date().toISOString(), - source: "offline", - }; }), }); diff --git a/server/routers/declarations.ts b/server/routers/declarations.ts index 3da29a8f..2147f4e8 100644 --- a/server/routers/declarations.ts +++ b/server/routers/declarations.ts @@ -44,7 +44,7 @@ async function computeRiskScore( traderId?: string; traderHistory?: { totalDeclarations: number; rejectionRate: number; amendmentRate: number; isAEO: boolean; monthsActive: number }; } -): Promise<{ score: number; lane: string; explanation: Record }> { +): Promise<{ score: number | null; lane: string; explanation: Record }> { // ── 1. Python ML risk scorer (primary) ────────────────────────────────────── if (opts?.declarationId && opts?.traderId) { try { @@ -151,13 +151,14 @@ Green: 0-30 (auto-clear), Yellow: 31-60 (doc review), Red: 61-100 (physical insp } catch (e) { console.error("[RiskScore] LLM error:", e); } - // Fallback: deterministic score based on HS code hash (no randomness) - const hsHash = data.hsCode ? data.hsCode.split("").reduce((a, c) => a + c.charCodeAt(0), 0) : 50; - const score = (hsHash % 40) + 10; return { - score, - lane: score < 30 ? "green" : score < 60 ? "yellow" : "red", - explanation: { summary: "Automated assessment", factors: [] } + score: null, + lane: "red", + explanation: { + source: "unavailable", + summary: "Automated risk scoring unavailable; manual inspection required", + factors: [], + } }; } @@ -263,7 +264,7 @@ export const declarationsRouter = router({ const updated = await updateDeclaration(input.id, { status: "under_assessment", - riskScore: String(risk.score), + riskScore: risk.score === null ? null : String(risk.score), riskLane: risk.lane as any, aiExplanation: risk.explanation, dutyAmount: String(duty.toFixed(2)), @@ -327,7 +328,7 @@ export const declarationsRouter = router({ declarationType: decl.declarationType, status: 'under_assessment', riskLane: risk.lane, - riskScore: String(risk.score), + riskScore: risk.score === null ? null : String(risk.score), hsCode: decl.hsCode, goodsDescription: decl.goodsDescription, countryOfOrigin: decl.countryOfOrigin, diff --git a/server/routers/fund-flow.ts b/server/routers/fund-flow.ts index 62c1de9a..ea7d8fc8 100644 --- a/server/routers/fund-flow.ts +++ b/server/routers/fund-flow.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { protectedProcedure, router } from "../_core/trpc"; +import { assertCan } from "../_core/permify"; import { TRPCError } from "@trpc/server"; import { getDb } from "../db"; import { @@ -48,16 +49,16 @@ async function getRedis() { /** * Atomic Redis idempotency guard. * Returns true if this key was already processed (duplicate). - * Fails open (returns false) if Redis is unavailable. + * Fails closed if Redis is unavailable. */ async function checkAndSetIdempotency(key: string, ttlSeconds = 86400): Promise { try { const r = await getRedis(); - if (!r) return false; + if (!r) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Idempotency service unavailable" }); const result = await r.set(`ff:idem:${key}`, "1", { NX: true, EX: ttlSeconds }); return result === null; // null → key existed → duplicate } catch { - return false; // fail open + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Idempotency service unavailable" }); } } @@ -69,48 +70,38 @@ async function triggerTemporalWorkflow( workflowType: string, input: Record ): Promise<{ workflowId: string; runId: string }> { - const resp = await fetch(`${WORKFLOW_SERVICE_URL}/workflows/trigger`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ workflow_type: workflowType, input }), - signal: AbortSignal.timeout(30_000), - }); - if (!resp.ok) { - const body = await resp.text(); + try { + const resp = await fetch(`${WORKFLOW_SERVICE_URL}/workflows/trigger`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workflow_type: workflowType, input }), + signal: AbortSignal.timeout(30_000), + }); + if (!resp.ok) { + const body = await resp.text(); + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: `Temporal workflow trigger failed: ${body}`, + }); + } + return resp.json() as Promise<{ workflowId: string; runId: string }>; + } catch (error) { + if (error instanceof TRPCError) throw error; throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Temporal workflow trigger failed: ${body}`, + code: "SERVICE_UNAVAILABLE", + message: "Temporal workflow service unavailable", }); } - return resp.json() as Promise<{ workflowId: string; runId: string }>; } -// ─── PERMIFY AUTHORIZATION ──────────────────────────────────────────────────── - -const PERMIFY_URL = process.env.PERMIFY_URL ?? "http://localhost:3476"; +const fundFlowProcedure = protectedProcedure.use(async ({ ctx, next }) => { + await assertCan(String(ctx.user.id), "fund_flow", "execute", "execute"); + return next(); +}); -async function checkPermify( - subjectType: string, - subjectId: string, - resource: string, - action: string -): Promise { - try { - const resp = await fetch(`${PERMIFY_URL}/v1/permissions/check`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - metadata: { schema_version: "", snap_token: "", depth: 20 }, - entity: { type: "resource", id: resource }, - permission: action, - subject: { type: subjectType, id: subjectId }, - }), - signal: AbortSignal.timeout(5_000), - }); - const data = (await resp.json()) as { can: string }; - return data.can === "CHECK_RESULT_ALLOWED"; - } catch { - return true; // fail open — Permify unavailable +function requireOfficer(role: string): void { + if (!["admin", "customs_officer", "oga_officer", "inspector", "finance"].includes(role)) { + throw new TRPCError({ code: "FORBIDDEN", message: "Officer access required" }); } } @@ -119,14 +110,14 @@ async function checkPermify( export const fundFlowRouter = router({ // ─── SCENARIO 1: Import Duty Collection ──────────────────────────────────── - collectImportDuty: protectedProcedure + collectImportDuty: fundFlowProcedure .input(z.object({ declarationId: z.number().int().positive(), idempotencyKey: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const db = await getDb(); - if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Database unavailable" }); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Database unavailable" }); const decl = await db.select().from(declarations).where(eq(declarations.id, input.declarationId)).limit(1).then(r => r[0]); if (!decl) throw new TRPCError({ code: "NOT_FOUND", message: "Declaration not found" }); if ((decl.status as string) !== "approved") @@ -147,10 +138,10 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 2: Export Levy Collection ──────────────────────────────────── - collectExportLevy: protectedProcedure + collectExportLevy: fundFlowProcedure .input(z.object({ declarationId: z.number().int().positive(), - levyAmountMinor: z.number().int().nonnegative(), + levyAmountMinor: z.number().int().positive(), })) .mutation(async ({ ctx, input }) => { const idemKey = `export_levy:${input.declarationId}`; @@ -165,7 +156,7 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 3: Duty Drawback Claim ─────────────────────────────────────── - submitDrawbackClaim: protectedProcedure + submitDrawbackClaim: fundFlowProcedure .input(z.object({ declarationId: z.number().int().positive(), claimedAmountMinor: z.number().int().positive(), @@ -174,7 +165,7 @@ export const fundFlowRouter = router({ .mutation(async ({ ctx, input }) => { const claimNumber = `DBC-${Date.now()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`; const db = await getDb(); - if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Database unavailable" }); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Database unavailable" }); const [claim] = await db.insert(dutyDrawbackClaims).values({ claimNumber, traderId: ctx.user.id, @@ -192,14 +183,13 @@ export const fundFlowRouter = router({ return { claimId: claim.id, status: "submitted" }; }), - approveDrawbackClaim: protectedProcedure + approveDrawbackClaim: fundFlowProcedure .input(z.object({ claimId: z.number().int().positive(), approvedAmountMinor: z.number().int().positive(), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN", message: "Only customs officers can approve drawback claims" }); + requireOfficer(ctx.user.role); const idemKey = `drawback_approve:${input.claimId}:${ctx.user.id}`; if (await checkAndSetIdempotency(idemKey)) @@ -214,15 +204,14 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 4: Penalty Levy ─────────────────────────────────────────────── - issuePenalty: protectedProcedure + issuePenalty: fundFlowProcedure .input(z.object({ declarationId: z.number().int().positive(), penaltyAmountMinor: z.number().int().positive(), reason: z.string().min(10), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN" }); + requireOfficer(ctx.user.role); const idemKey = `penalty:${input.declarationId}:${ctx.user.id}`; if (await checkAndSetIdempotency(idemKey)) @@ -238,7 +227,7 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 5: Bond Guarantee Lodgement ────────────────────────────────── - lodgeBondGuarantee: protectedProcedure + lodgeBondGuarantee: fundFlowProcedure .input(z.object({ bondType: z.enum(["general_bond", "specific_bond", "transit_bond"]), amountMinor: z.number().int().positive(), @@ -265,14 +254,13 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 6 & 7: Bond Release / Forfeiture ───────────────────────────── - releaseBond: protectedProcedure + releaseBond: fundFlowProcedure .input(z.object({ bondId: z.number().int().positive(), clearancePermitRef: z.string(), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN" }); + requireOfficer(ctx.user.role); const idemKey = `bond_release:${input.bondId}:${input.clearancePermitRef}`; if (await checkAndSetIdempotency(idemKey)) @@ -287,14 +275,13 @@ export const fundFlowRouter = router({ return { workflowId: wf.workflowId, idempotent: false }; }), - forfeitBond: protectedProcedure + forfeitBond: fundFlowProcedure .input(z.object({ bondId: z.number().int().positive(), reason: z.string().min(10), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN" }); + requireOfficer(ctx.user.role); const idemKey = `bond_forfeiture:${input.bondId}:${ctx.user.id}`; if (await checkAndSetIdempotency(idemKey)) @@ -310,7 +297,7 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 8 & 9: Transit Guarantee ───────────────────────────────────── - lodgeTransitGuarantee: protectedProcedure + lodgeTransitGuarantee: fundFlowProcedure .input(z.object({ transitId: z.number().int().positive(), amountMinor: z.number().int().positive(), @@ -334,15 +321,14 @@ export const fundFlowRouter = router({ return { workflowId: wf.workflowId, idempotent: false }; }), - releaseTransitGuarantee: protectedProcedure + releaseTransitGuarantee: fundFlowProcedure .input(z.object({ transitId: z.number().int().positive(), exitConfirmRef: z.string(), ucr: z.string(), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN" }); + requireOfficer(ctx.user.role); const idemKey = `transit_release:${input.transitId}:${input.exitConfirmRef}`; if (await checkAndSetIdempotency(idemKey)) @@ -358,7 +344,7 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 10: AEO Application Fee ────────────────────────────────────── - payAeoFee: protectedProcedure + payAeoFee: fundFlowProcedure .input(z.object({ applicationId: z.number().int().positive(), feeAmountMinor: z.number().int().positive(), @@ -377,7 +363,7 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 11: Free Zone Entry Fee ────────────────────────────────────── - payFreeZoneEntryFee: protectedProcedure + payFreeZoneEntryFee: fundFlowProcedure .input(z.object({ admissionId: z.number().int().positive(), feeAmountMinor: z.number().int().positive(), @@ -396,7 +382,7 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 12: Warehouse Storage Fee ──────────────────────────────────── - payWarehouseStorageFee: protectedProcedure + payWarehouseStorageFee: fundFlowProcedure .input(z.object({ inventoryId: z.number().int().positive(), feeAmountMinor: z.number().int().positive(), @@ -417,7 +403,7 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 13: Ex-Bond Duty Payment ───────────────────────────────────── - payExBondDuty: protectedProcedure + payExBondDuty: fundFlowProcedure .input(z.object({ permitId: z.number().int().positive(), dutyAmountMinor: z.number().int().positive(), @@ -436,7 +422,7 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 14: Post-Clearance Audit Recovery ──────────────────────────── - initiateAuditRecovery: protectedProcedure + initiateAuditRecovery: fundFlowProcedure .input(z.object({ auditId: z.number().int().positive(), declarationId: z.number().int().positive(), @@ -445,8 +431,7 @@ export const fundFlowRouter = router({ paymentDeadline: z.string(), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN" }); + requireOfficer(ctx.user.role); const idemKey = `audit_recovery:${input.auditId}:${input.demandNoticeRef}`; if (await checkAndSetIdempotency(idemKey)) @@ -464,15 +449,14 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 15: Overpayment Refund ─────────────────────────────────────── - initiateOverpaymentRefund: protectedProcedure + initiateOverpaymentRefund: fundFlowProcedure .input(z.object({ auditId: z.number().int().positive(), declarationId: z.number().int().positive(), overpaidMinor: z.number().int().positive(), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN" }); + requireOfficer(ctx.user.role); const idemKey = `overpayment_refund:${input.auditId}:${ctx.user.id}`; if (await checkAndSetIdempotency(idemKey)) @@ -488,7 +472,7 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 16: OGA Permit Fee ─────────────────────────────────────────── - payOgaPermitFee: protectedProcedure + payOgaPermitFee: fundFlowProcedure .input(z.object({ permitApplicationId: z.number().int().positive(), feeAmountMinor: z.number().int().positive(), @@ -507,15 +491,14 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 17: Sanctions-Blocked Payment Reversal ─────────────────────── - reverseSanctionedPayment: protectedProcedure + reverseSanctionedPayment: fundFlowProcedure .input(z.object({ declarationId: z.number().int().positive(), reservedTigerBeetleTxId: z.string(), sanctionsRef: z.string(), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN" }); + requireOfficer(ctx.user.role); const idemKey = `sanctions_reversal:${input.declarationId}:${input.sanctionsRef}`; if (await checkAndSetIdempotency(idemKey)) @@ -531,14 +514,13 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 18: Batch Payment Settlement ───────────────────────────────── - triggerBatchSettlement: protectedProcedure + triggerBatchSettlement: fundFlowProcedure .input(z.object({ batchId: z.string(), settlementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN" }); + requireOfficer(ctx.user.role); const idemKey = `batch_settlement:${input.batchId}`; if (await checkAndSetIdempotency(idemKey)) @@ -568,14 +550,13 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 19: Revenue Reconciliation ─────────────────────────────────── - triggerRevenueReconciliation: protectedProcedure + triggerRevenueReconciliation: fundFlowProcedure .input(z.object({ reconciliationDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), alertThresholdMinor: z.number().int().nonnegative().default(100_000), })) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "admin") - throw new TRPCError({ code: "FORBIDDEN" }); + requireOfficer(ctx.user.role); const idemKey = `revenue_reconciliation:${input.reconciliationDate}`; if (await checkAndSetIdempotency(idemKey, 3600)) // 1 hour TTL — allow re-run same day @@ -590,11 +571,13 @@ export const fundFlowRouter = router({ }), // ─── SCENARIO 20: Trader Account Provisioning ────────────────────────────── - provisionTraderAccount: protectedProcedure + provisionTraderAccount: fundFlowProcedure .input(z.object({ currency: z.string().length(3).default("NGN"), })) .mutation(async ({ ctx, input }) => { + requireOfficer(ctx.user.role); + const idemKey = `account_provisioning:${ctx.user.id}:${input.currency}`; if (await checkAndSetIdempotency(idemKey)) return { idempotent: true, message: "Account already provisioned" }; @@ -608,7 +591,7 @@ export const fundFlowRouter = router({ }), // ─── QUERY: Fund Flow Status ──────────────────────────────────────────────── - getWorkflowStatus: protectedProcedure + getWorkflowStatus: fundFlowProcedure .input(z.object({ workflowId: z.string() })) .query(async ({ input }) => { const resp = await fetch( diff --git a/server/routers/ledger.ts b/server/routers/ledger.ts index ac332773..26a9998f 100644 --- a/server/routers/ledger.ts +++ b/server/routers/ledger.ts @@ -2,7 +2,6 @@ * ledger.ts — tRPC router for TigerBeetle double-entry ledger (Sprint 31) * * Proxies to the Rust tigerbeetle-bridge service (port 8093). - * Falls back to DB-persisted ledger entries when the bridge is unavailable. * * Procedures: * ledger.getAccount — get account details and balance @@ -25,7 +24,6 @@ import { publishEvent, TOPICS } from "../_core/kafka"; import { getLedgerEntriesByDeclaration, getLedgerEntriesByPayment, - getRecentLedgerEntries, createLedgerEntry, } from "../db"; @@ -155,23 +153,7 @@ export const ledgerRouter = router({ return result; } - // Fallback: persist to DB only - const entry = await createLedgerEntry({ - tbTransferId: crypto.randomUUID(), - debitAccountId: input.debitAccountId, - creditAccountId: input.creditAccountId, - amountMinorUnits: Math.round(parseFloat(input.amount) * 100), - currency: input.currency, - ledger: 1, - entryType: "duty_payment", - status: "posted", - declarationId: input.declarationId, - paymentId: input.paymentId, - reference: input.reference, - description: input.description, - postedAt: new Date(), - }); - return { ...entry, _source: "db_fallback" }; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle bridge is unavailable" }); }), /** @@ -260,22 +242,14 @@ export const ledgerRouter = router({ /** * Get ledger summary: all account balances + recent transfers. - * Calls the Go bridge; falls back to DB recent entries. + * Calls the Go bridge, which is the ledger of record. */ getSummary: protectedProcedure.query(async () => { const available = await tbBridgeAvailable(); if (available) { return tbFetch>("/api/ledger/summary"); } - // DB fallback - const recent = await getRecentLedgerEntries(20); - return { - recentTransfers: recent, - summary: { - mode: "db_fallback", - note: "TigerBeetle bridge unavailable — showing DB ledger entries only", - }, - }; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle bridge is unavailable" }); }), /** @@ -298,45 +272,40 @@ export const ledgerRouter = router({ .mutation(async ({ input }) => { const available = await riskScorerAvailable(); if (!available) { - // Return a default LOW risk score when scorer is unavailable - return { - traderId: input.traderId, - riskScore: 0.10, - riskTier: "LOW", - recommendedAction: "APPROVE", - flags: ["SCORER_UNAVAILABLE: risk scorer offline — defaulting to LOW"], - modelVersion: "fallback", - scoredAt: new Date().toISOString(), - _source: "fallback", - }; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Payment risk scorer is unavailable" }); } - const res = await fetch(`${PAYMENT_RISK_URL}/api/payment-risk/score`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - trader_id: input.traderId, - declaration_id: input.declarationId, - amount: input.amount, - currency: input.currency, - fsp_id: input.fspId, - fsp_type: input.fspType, - payer_account: input.payerAccount, - declaration_value: input.declarationValue, - trader_compliance_score: input.traderComplianceScore, - is_first_payment: input.isFirstPayment, - }), - signal: AbortSignal.timeout(5_000), - }); - - if (!res.ok) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Payment risk scorer error: ${res.status}`, + try { + const res = await fetch(`${PAYMENT_RISK_URL}/api/payment-risk/score`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + trader_id: input.traderId, + declaration_id: input.declarationId, + amount: input.amount, + currency: input.currency, + fsp_id: input.fspId, + fsp_type: input.fspType, + payer_account: input.payerAccount, + declaration_value: input.declarationValue, + trader_compliance_score: input.traderComplianceScore, + is_first_payment: input.isFirstPayment, + }), + signal: AbortSignal.timeout(5_000), }); - } - return res.json(); + if (!res.ok) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: `Payment risk scorer error: ${res.status}`, + }); + } + + return res.json(); + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Payment risk scorer is unavailable" }); + } }), /** @@ -355,19 +324,7 @@ export const ledgerRouter = router({ .mutation(async ({ input, ctx }) => { const available = await tbBridgeAvailable(); if (!available) { - return await createLedgerEntry({ - declarationId: input.declarationId, - paymentId: null, - entryType: "bond_deposit", - debitAccountId: `trader-${input.traderId}-liability`, - creditAccountId: `bond-${input.traderId}-${input.bondType}`, - amountMinorUnits: Math.round(input.bondAmount * 100), - tbTransferId: `TB-BOND-DEP-${input.declarationId}-${Date.now()}`, - currency: input.currency, - reference: `BOND-DEP-${input.declarationId}-${input.bondType}`, - status: "posted", - metadata: { bondType: input.bondType, expiryDate: input.expiryDate, _source: "db-ledger-fallback", _tag: "offline-stub" }, - }); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle bridge is unavailable" }); } return tbFetch>("/bond/deposit", { method: "POST", @@ -398,19 +355,7 @@ export const ledgerRouter = router({ .mutation(async ({ input }) => { const available = await tbBridgeAvailable(); if (!available) { - return await createLedgerEntry({ - declarationId: input.declarationId, - paymentId: null, - entryType: "bond_release", - debitAccountId: `bond-${input.traderId}-${input.bondType}`, - creditAccountId: `trader-${input.traderId}-liability`, - amountMinorUnits: Math.round(input.bondAmount * 100), - tbTransferId: `TB-BOND-REL-${input.declarationId}-${Date.now()}`, - currency: input.currency, - reference: `BOND-REL-${input.declarationId}-${input.releaseReason}`, - status: "posted", - metadata: { bondType: input.bondType, releaseReason: input.releaseReason, _source: "db-ledger-fallback", _tag: "offline-stub" }, - }); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle bridge is unavailable" }); } return tbFetch>("/bond/release", { method: "POST", @@ -441,19 +386,7 @@ export const ledgerRouter = router({ .mutation(async ({ input }) => { const available = await tbBridgeAvailable(); if (!available) { - return await createLedgerEntry({ - declarationId: input.declarationId, - paymentId: null, - entryType: "penalty", - debitAccountId: `trader-${input.traderId}-liability`, - creditAccountId: `penalty-revenue-${input.penaltyCode}`, - amountMinorUnits: Math.round(input.penaltyAmount * 100), - tbTransferId: `TB-PENALTY-${input.declarationId}-${Date.now()}`, - currency: input.currency, - reference: `PENALTY-${input.declarationId}-${input.penaltyCode}`, - status: "posted", - metadata: { penaltyCode: input.penaltyCode, officerId: input.officerId, _source: "db-ledger-fallback", _tag: "offline-stub" }, - }); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle bridge is unavailable" }); } return tbFetch>("/penalty", { method: "POST", @@ -484,19 +417,7 @@ export const ledgerRouter = router({ .mutation(async ({ input }) => { const available = await tbBridgeAvailable(); if (!available) { - return await createLedgerEntry({ - declarationId: input.declarationId, - paymentId: null, - entryType: "adjustment", // closest existing type; schema will add transit_guarantee in v77 - debitAccountId: `trader-${input.traderId}-liability`, - creditAccountId: `transit-guarantee-${input.traderId}-${input.destinationCountry}`, - amountMinorUnits: Math.round(input.guaranteeAmount * 100), - tbTransferId: `TB-TRANSIT-${input.declarationId}-${Date.now()}`, - currency: input.currency, - reference: `TRANSIT-${input.declarationId}-${input.destinationCountry}`, - status: "posted", - metadata: { destinationCountry: input.destinationCountry, transitDays: input.transitDays, _source: "db-ledger-fallback", _tag: "offline-stub" }, - }); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle bridge is unavailable" }); } return tbFetch>("/transit-guarantee", { method: "POST", diff --git a/server/routers/mojaloop.ts b/server/routers/mojaloop.ts index 3fc3bbe7..a6322f43 100644 --- a/server/routers/mojaloop.ts +++ b/server/routers/mojaloop.ts @@ -28,7 +28,7 @@ import { getDb } from "../db"; import { paymentIdempotencyKeys } from "../../drizzle/schema"; import { eq } from "drizzle-orm"; import { z } from "zod"; -import { protectedProcedure, publicProcedure, router } from "../_core/trpc"; +import { protectedProcedure, router } from "../_core/trpc"; import { getPaymentsByDeclaration, createMojaloopTransaction, @@ -37,14 +37,12 @@ import { getMojaloopTransactionsByDeclaration, getMojaloopTransactionsByUser, logAuditEvent, - createLedgerEntry, - updatePayment, + getDeclarationById, } from "../db"; const MOJALOOP_URL = process.env.MOJALOOP_URL || "http://localhost:3003"; const MOJALOOP_API_KEY = process.env.MOJALOOP_API_KEY || ""; // Shared secret for verifying webhook callbacks from the Mojaloop switch -const MOJALOOP_WEBHOOK_SECRET = process.env.MOJALOOP_WEBHOOK_SECRET || "dev-webhook-secret"; // ─── Mojaloop service client ─────────────────────────────────────────────── @@ -155,8 +153,6 @@ function generateCondition(): string { // In production these would be fetched from the TB bridge service. // For simulation, we use fixed account IDs for the customs authority ledger. -const TB_CUSTOMS_REVENUE_ACCOUNT = "0000000000000001"; // Customs revenue credit account -const TB_TRADER_DEBIT_ACCOUNT = "0000000000000002"; // Trader debit account (per-trader in prod) // ─── Router ─────────────────────────────────────────────────────────────── @@ -196,7 +192,6 @@ export const mojaloopRouter = router({ message: `Exchange rate not available for ${input.fromCurrency}/${input.toCurrency}`, }); } - return { fromCurrency: input.fromCurrency, toCurrency: input.toCurrency, @@ -222,14 +217,34 @@ export const mojaloopRouter = router({ paymentNote: z.string().max(128).optional(), })) .mutation(async ({ input, ctx }) => { + const decl = await getDeclarationById(input.declarationId); + if (!decl) throw new TRPCError({ code: "NOT_FOUND", message: "Declaration not found" }); + const privileged = ["admin", "customs_officer", "finance", "oga_officer"].includes(ctx.user.role); + if (!privileged && decl.traderId !== ctx.user.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "You do not own this declaration" }); + } + const payableAmount = Number(decl.totalDue); + if (!Number.isFinite(payableAmount) || payableAmount <= 0) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Declaration has no payable amount" }); + } + if (Math.abs(input.amount - payableAmount) > 0.005) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Payment amount does not match declaration total due" }); + } const fsp = SUPPORTED_FSPS.find(f => f.fspId === input.fspId); if (!fsp) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown FSP: ${input.fspId}` }); } + const declarationCurrency = decl.invoiceCurrency ?? input.currency; + if (input.currency !== declarationCurrency || declarationCurrency !== fsp.currency) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Declaration is denominated in ${declarationCurrency}, but ${fsp.name} settles in ${fsp.currency}; FX conversion is required and not implemented.`, + }); + } if (!fsp.active) { throw new TRPCError({ code: "BAD_REQUEST", message: `FSP ${fsp.name} is currently unavailable` }); } - if (input.amount < fsp.minAmount || input.amount > fsp.maxAmount) { + if (payableAmount < fsp.minAmount || payableAmount > fsp.maxAmount) { throw new TRPCError({ code: "BAD_REQUEST", message: `Amount must be between ${fsp.minAmount} and ${fsp.maxAmount} ${fsp.currency} for ${fsp.name}`, @@ -238,7 +253,7 @@ export const mojaloopRouter = router({ // ── Idempotency check (1B payments/day pattern) ───────────────────────── // Hash: userId + declarationId + amount + currency + fspId + payerAccount - const idempotencyInput = `${ctx.user.id}:${input.declarationId}:${input.amount}:${input.currency}:${input.fspId}:${input.payerAccount}`; + const idempotencyInput = `${ctx.user.id}:${input.declarationId}:${payableAmount}:${input.currency}:${input.fspId}:${input.payerAccount}`; const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(idempotencyInput)); const keyHash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join(""); @@ -269,7 +284,7 @@ export const mojaloopRouter = router({ fspType: fsp.type as FspType, payerAccount: input.payerAccount, payerName: input.payerName, - amount: input.amount.toString(), + amount: payableAmount.toString(), currency: input.currency, status: "PENDING", ilpPacket, @@ -295,41 +310,47 @@ export const mojaloopRouter = router({ action: "mojaloop_payment_initiated", actorId: ctx.user.id, actorType: "trader", - newState: { transferId, fspId: input.fspId, amount: input.amount, currency: input.currency }, + newState: { transferId, fspId: input.fspId, amount: payableAmount, currency: input.currency }, }); // Forward to live Mojaloop switch if available const available = await mojaloopAvailable(); - if (available) { - try { - await fetch(`${MOJALOOP_URL}/transfers`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${MOJALOOP_API_KEY}`, - "FSPIOP-Source": "CUSTOMS_AUTHORITY", - "FSPIOP-Destination": input.fspId, - }, - body: JSON.stringify({ - transferId, - payerFsp: input.fspId, - payeeFsp: "CUSTOMS_AUTHORITY", - amount: { amount: input.amount.toString(), currency: input.currency }, - ilpPacket, - condition, - expiration: expiresAt.toISOString(), - }), - signal: AbortSignal.timeout(10_000), - }); - } catch (e) { - console.warn(`[Mojaloop] Transfer request failed: ${e}. Using simulation.`); + if (!available) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Mojaloop switch is unavailable" }); + } + try { + const response = await fetch(`${MOJALOOP_URL}/transfers`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${MOJALOOP_API_KEY}`, + "FSPIOP-Source": "CUSTOMS_AUTHORITY", + "FSPIOP-Destination": input.fspId, + }, + body: JSON.stringify({ + transferId, + payerFsp: input.fspId, + payeeFsp: "CUSTOMS_AUTHORITY", + amount: { amount: payableAmount.toString(), currency: input.currency }, + ilpPacket, + condition, + expiration: expiresAt.toISOString(), + }), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + const detail = await response.text().catch(() => response.statusText); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: `Mojaloop transfer rejected (${response.status}): ${detail}` }); } + } catch (e) { + if (e instanceof TRPCError) throw e; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Mojaloop transfer request failed" }); } return { transferId, status: "PENDING", - amount: input.amount, + amount: payableAmount, currency: input.currency, fspName: fsp.name, fspType: fsp.type, @@ -338,22 +359,27 @@ export const mojaloopRouter = router({ expiresAt: expiresAt.toISOString(), paymentInstructions: fsp.type === "MOBILE_MONEY" ? `Approve the payment request on your ${fsp.name} app or dial *170# to complete payment.` - : `Transfer ${input.amount} ${input.currency} to account: CUSTOMS-DUTY-${input.declarationId} at ${fsp.name}.`, - simulationNote: !available ? "Running in simulation mode — no real payment processed." : undefined, + : `Transfer ${payableAmount} ${input.currency} to account: CUSTOMS-DUTY-${input.declarationId} at ${fsp.name}.`, }; }), /** * Get the current status of a Mojaloop payment transfer. - * Reads from DB first; simulates state progression in dev mode. + * Reads the persisted transfer state without mutating it. */ getPaymentStatus: protectedProcedure .input(z.object({ transferId: z.string() })) - .query(async ({ input }) => { + .query(async ({ ctx, input }) => { + const privileged = ["admin", "customs_officer", "finance", "oga_officer"].includes(ctx.user.role); // Try DB first const dbRecord = await getMojaloopTransactionByTransferId(input.transferId); if (!dbRecord) { + // An ordinary caller cannot establish ownership of a transfer that is + // not present in this application's transaction store. + if (!privileged) { + throw new TRPCError({ code: "NOT_FOUND", message: "Transfer not found" }); + } // Try live Mojaloop API const available = await mojaloopAvailable(); if (available) { @@ -368,58 +394,13 @@ export const mojaloopRouter = router({ throw new TRPCError({ code: "NOT_FOUND", message: "Transfer not found" }); } - // Simulate state progression in dev/simulation mode - const elapsedMs = Date.now() - dbRecord.createdAt.getTime(); - let status = dbRecord.status; - - if (status === "PENDING" && elapsedMs > 5_000) { - status = "PROCESSING"; - await updateMojaloopTransaction(input.transferId, { status }); - } - - if (status === "PROCESSING" && elapsedMs > 15_000) { - const fulfilment = generateCondition(); - status = "COMMITTED"; - await updateMojaloopTransaction(input.transferId, { - status, - fulfilment, - committedAt: new Date(), - }); - - // Create TigerBeetle ledger entry for this settlement - const tbTransferId = crypto.randomUUID().replace(/-/g, "").slice(0, 32).padStart(32, "0"); - await createLedgerEntry({ - tbTransferId, - debitAccountId: TB_TRADER_DEBIT_ACCOUNT, - creditAccountId: TB_CUSTOMS_REVENUE_ACCOUNT, - amountMinorUnits: Math.round(dbRecord.amount as unknown as number * 100), - currency: dbRecord.currency, - ledger: 1, - entryType: "duty_payment", - status: "posted", - declarationId: dbRecord.declarationId ?? undefined, - mojaloopTransferId: input.transferId, - reference: `DUTY-${dbRecord.declarationId ?? "N/A"}`, - description: `Duty payment via ${dbRecord.fspName} (${input.transferId})`, - postedAt: new Date(), - }).catch(e => console.warn("[TigerBeetle] Failed to create ledger entry:", e)); - - // Log audit event for settlement - await logAuditEvent({ - entityType: "payment", - entityId: dbRecord.id, - action: "mojaloop_payment_committed", - actorId: dbRecord.initiatedBy, - actorType: "system", - newState: { transferId: input.transferId, status: "COMMITTED", fulfilment }, - }); + if (!privileged && dbRecord.initiatedBy !== ctx.user.id) { + throw new TRPCError({ code: "FORBIDDEN" }); } - const updated = await getMojaloopTransactionByTransferId(input.transferId); - return { transferId: input.transferId, - status: updated?.status ?? status, + status: dbRecord.status, amount: Number(dbRecord.amount), currency: dbRecord.currency, fspId: dbRecord.fspId, @@ -427,12 +408,12 @@ export const mojaloopRouter = router({ fspType: dbRecord.fspType, payerAccount: dbRecord.payerAccount, createdAt: dbRecord.createdAt.toISOString(), - committedAt: updated?.committedAt?.toISOString() ?? null, + committedAt: dbRecord.committedAt?.toISOString() ?? null, ilpPacket: dbRecord.ilpPacket, condition: dbRecord.condition, - fulfilment: updated?.fulfilment ?? null, - isSettled: (updated?.status ?? status) === "COMMITTED", - isFailed: (updated?.status ?? status) === "ABORTED", + fulfilment: dbRecord.fulfilment ?? null, + isSettled: dbRecord.status === "COMMITTED", + isFailed: dbRecord.status === "ABORTED", paymentInstructions: dbRecord.fspType === "MOBILE_MONEY" ? `Approve the payment request on your ${dbRecord.fspName} app or dial *170# to complete payment.` : `Transfer to account: CUSTOMS-DUTY-${dbRecord.declarationId} at ${dbRecord.fspName}.`, @@ -466,93 +447,6 @@ export const mojaloopRouter = router({ return getPaymentsByDeclaration(input.declarationId); }), - /** - * Webhook callback from Mojaloop switch. - * Verifies the shared secret header and updates the transaction status. - * In production, this would be called by the Mojaloop switch directly. - */ - webhookCallback: publicProcedure - .input(z.object({ - transferId: z.string(), - transferState: z.enum(["RECEIVED", "RESERVED", "COMMITTED", "ABORTED"]), - fulfilment: z.string().optional(), - completedTimestamp: z.string().optional(), - errorInformation: z.object({ - errorCode: z.string(), - errorDescription: z.string(), - }).optional(), - webhookSecret: z.string(), - })) - .mutation(async ({ input }) => { - // Verify webhook secret - if (input.webhookSecret !== MOJALOOP_WEBHOOK_SECRET) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid webhook secret" }); - } - - const tx = await getMojaloopTransactionByTransferId(input.transferId); - if (!tx) { - throw new TRPCError({ code: "NOT_FOUND", message: "Transfer not found" }); - } - - const updateData: Record = { - status: input.transferState, - webhookPayload: input, - }; - - if (input.transferState === "COMMITTED") { - updateData.fulfilment = input.fulfilment ?? null; - updateData.committedAt = input.completedTimestamp - ? new Date(input.completedTimestamp) - : new Date(); - - // Create TigerBeetle ledger entry - const tbTransferId = crypto.randomUUID().replace(/-/g, "").slice(0, 32).padStart(32, "0"); - await createLedgerEntry({ - tbTransferId, - debitAccountId: TB_TRADER_DEBIT_ACCOUNT, - creditAccountId: TB_CUSTOMS_REVENUE_ACCOUNT, - amountMinorUnits: Math.round(Number(tx.amount) * 100), - currency: tx.currency, - ledger: 1, - entryType: "duty_payment", - status: "posted", - declarationId: tx.declarationId ?? undefined, - mojaloopTransferId: input.transferId, - reference: `DUTY-${tx.declarationId ?? "N/A"}`, - description: `Duty payment settled via Mojaloop webhook (${input.transferId})`, - postedAt: new Date(), - }).catch(e => console.warn("[TigerBeetle] Webhook ledger entry failed:", e)); - - // Log audit event - await logAuditEvent({ - entityType: "payment", - entityId: tx.id, - action: "mojaloop_webhook_committed", - actorId: tx.initiatedBy, - actorType: "system", - newState: { transferId: input.transferId, status: "COMMITTED" }, - }); - } - - if (input.transferState === "ABORTED") { - updateData.abortedAt = new Date(); - updateData.failureReason = input.errorInformation?.errorDescription ?? "Transfer aborted"; - - await logAuditEvent({ - entityType: "payment", - entityId: tx.id, - action: "mojaloop_webhook_aborted", - actorId: tx.initiatedBy, - actorType: "system", - newState: { transferId: input.transferId, status: "ABORTED", error: input.errorInformation }, - }); - } - - await updateMojaloopTransaction(input.transferId, updateData as any); - - return { success: true, transferId: input.transferId, newStatus: input.transferState }; - }), - /** * Get a summary of the Mojaloop integration status and recent transactions. */ @@ -561,7 +455,7 @@ export const mojaloopRouter = router({ return { connected: available, - mode: available ? "LIVE" : "SIMULATION", + mode: available ? "LIVE" : "UNAVAILABLE", switchUrl: MOJALOOP_URL, supportedFSPs: SUPPORTED_FSPS.filter(f => f.active).length, ilpVersion: "v4", diff --git a/server/routers/onboarding.ts b/server/routers/onboarding.ts index 9ef9d310..948501d5 100644 --- a/server/routers/onboarding.ts +++ b/server/routers/onboarding.ts @@ -231,11 +231,9 @@ export const onboardingRouter = router({ } return { success: true, nextStep: isLastStep ? null : nextStep, isComplete: isLastStep }; - } catch { - // DB not available — return success for sandbox - const stepIdx = STEPS.indexOf(input.step as OnboardingStep); - const nextStep = stepIdx < STEPS.length - 1 ? STEPS[stepIdx + 1] : null; - return { success: true, nextStep, isComplete: stepIdx === STEPS.length - 1 }; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Onboarding progress could not be saved" }); } }), @@ -245,13 +243,14 @@ export const onboardingRouter = router({ resetOnboarding: protectedProcedure.mutation(async ({ ctx }) => { try { const db = await (await import("../db")).getDb(); - if (!db) return { success: true }; + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Database unavailable" }); const { onboardingProgress } = await import("../../drizzle/schema"); const { eq } = await import("drizzle-orm"); await db.delete(onboardingProgress).where(eq(onboardingProgress.userId, ctx.user.id)); return { success: true }; - } catch { - return { success: true }; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Onboarding reset failed" }); } }), @@ -271,12 +270,12 @@ export const onboardingRouter = router({ */ selectRole: protectedProcedure .input(z.object({ - role: z.enum(["user", "customs_officer", "oga_officer", "inspector", "finance"]), + role: z.enum(["user"]), })) .mutation(async ({ ctx, input }) => { try { const db = await (await import("../db")).getDb(); - if (!db) return { success: true, role: input.role }; + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Database unavailable" }); const { users } = await import("../../drizzle/schema"); const { eq } = await import("drizzle-orm"); await db.update(users) @@ -298,10 +297,12 @@ export const onboardingRouter = router({ await writeRelationship("organisation", "main", relation, "user", userId); } catch (permifyErr) { console.warn("[Permify] Failed to seed role relation:", permifyErr); + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Authorization setup unavailable" }); } return { success: true, role: input.role }; - } catch { - return { success: true, role: input.role }; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Role selection failed" }); } }), diff --git a/server/routers/payments.ts b/server/routers/payments.ts index 766e4a9d..59e6ff85 100644 --- a/server/routers/payments.ts +++ b/server/routers/payments.ts @@ -17,7 +17,7 @@ import { eq, desc, and, gte, lte, count, sql, or } from "drizzle-orm"; import { nanoid } from "nanoid"; import { assertCan, setOwner } from "../_core/permify"; import { getDb } from "../db"; -import { emitPaymentInitiated, emitPaymentCompleted } from "../_core/kafkaEventPublisher"; +import { emitPaymentInitiated } from "../_core/kafkaEventPublisher"; import { getOrProvisionTraderAccount, SYSTEM_ACCOUNTS } from "../_core/paymentAccountProvisioner"; export const paymentsRouter = router({ @@ -60,54 +60,60 @@ export const paymentsRouter = router({ await updateDeclaration(input.declarationId, { status: "payment_pending" }); // Enqueue into batchPayments for async Mojaloop ILP processing + const db = await getDb(); try { - const db = await getDb(); + if (!db) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Payment queue unavailable" }); + } + const { paymentQueue, paymentIdempotencyKeys } = await import("../../drizzle/schema"); + const amountMinorUnits = BigInt(Math.round(parseFloat(decl.totalDue ?? "0") * 100)); + const debitAccountId = input.debitAccountId ?? traderAccountId; + const creditAccountId = input.creditAccountId ?? SYSTEM_ACCOUNTS.NCS_REVENUE; + const transferId = `tg-${reference}`; + + // Idempotency check — inline sha256 to avoid circular import + const keyHash = await (async (s: string) => { + const enc = new TextEncoder(); + const buf = await crypto.subtle.digest("SHA-256", enc.encode(s)); + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join(""); + })(`enqueue:${transferId}`); + const [existing] = await db.select().from(paymentIdempotencyKeys) + .where(eq(paymentIdempotencyKeys.keyHash, keyHash)).limit(1); + + if (!existing) { + const [inserted] = await db.insert(paymentQueue).values({ + transferId, + debitAccountId, + creditAccountId, + amountMinorUnits, + currency: (decl.invoiceCurrency ?? "USD").substring(0, 3), + ledger: 1, + metadata: { + declarationId: input.declarationId, + declarationNumber: decl.declarationNumber, + paymentId: payment?.id, + paymentMethod: input.paymentMethod, + traderId: ctx.user.id, + }, + status: "queued", + attemptCount: 0, + }).returning({ id: paymentQueue.id }); + + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + await db.insert(paymentIdempotencyKeys).values({ + keyHash, + transferId, + responseSnapshot: { queueId: inserted.id, status: "queued", paymentId: payment?.id }, + expiresAt, + }); + } + } catch (error) { if (db) { - const { paymentQueue, paymentIdempotencyKeys } = await import("../../drizzle/schema"); - const amountMinorUnits = BigInt(Math.round(parseFloat(decl.totalDue ?? "0") * 100)); - const debitAccountId = input.debitAccountId ?? traderAccountId; - const creditAccountId = input.creditAccountId ?? SYSTEM_ACCOUNTS.NCS_REVENUE; - const transferId = `tg-${reference}`; - - // Idempotency check — inline sha256 to avoid circular import - const keyHash = await (async (s: string) => { - const enc = new TextEncoder(); - const buf = await crypto.subtle.digest("SHA-256", enc.encode(s)); - return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join(""); - })(`enqueue:${transferId}`); - const [existing] = await db.select().from(paymentIdempotencyKeys) - .where(eq(paymentIdempotencyKeys.keyHash, keyHash)).limit(1); - - if (!existing) { - const [inserted] = await db.insert(paymentQueue).values({ - transferId, - debitAccountId, - creditAccountId, - amountMinorUnits, - currency: (decl.invoiceCurrency ?? "USD").substring(0, 3), - ledger: 1, - metadata: { - declarationId: input.declarationId, - declarationNumber: decl.declarationNumber, - paymentId: payment?.id, - paymentMethod: input.paymentMethod, - traderId: ctx.user.id, - }, - status: "queued", - attemptCount: 0, - }).returning({ id: paymentQueue.id }); - - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - await db.insert(paymentIdempotencyKeys).values({ - keyHash, - transferId, - responseSnapshot: { queueId: inserted.id, status: "queued", paymentId: payment?.id }, - expiresAt, - }); - } + await db.delete(payments).where(eq(payments.id, payment.id)).catch(() => {}); } - } catch { - // Non-blocking: payment record already created, queue failure is recoverable + throw error instanceof TRPCError + ? error + : new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Payment could not be queued" }); } await logAuditEvent({ @@ -133,12 +139,10 @@ export const paymentsRouter = router({ return { ...payment, queuedForProcessing: true }; }), - // ── CONFIRM PAYMENT (Mojaloop webhook → Temporal saga → TigerBeetle post) ──── - // Called by the Mojaloop switch when a transfer is COMMITTED. - // Triggers ConfirmPaymentWorkflow which atomically: - // 1. Posts the pending TigerBeetle transfer (irrevocable settlement) - // 2. Marks the payment confirmed in PostgreSQL - // 3. Emits payment.confirmed to Kafka via transactional outbox + // ── CONFIRM PAYMENT (Mojaloop webhook → Temporal saga → TigerBeetle post) ──── + // Starts ConfirmPaymentWorkflow after authorization. The workflow's payment + // service activity posts the pending TigerBeetle transfer, updates the + // payment-service invoice, and publishes payment.confirmed after settlement. // // Security: HMAC-SHA256 signature verification prevents spoofed callbacks. // Idempotency: Redis cache prevents duplicate processing of the same transferId. @@ -147,14 +151,13 @@ export const paymentsRouter = router({ paymentId: z.number().int().positive(), mojaloopTransferId: z.string().optional(), tbPendingTransferId: z.string().optional(), - signature: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); // Acquire distributed lock — prevents concurrent double-confirmation - const { acquireLock, releaseLock, getIdempotencyKey, setIdempotencyKey } = await import("../_core/distributedLock"); + const { acquireLock, releaseLock, getIdempotencyKey } = await import("../_core/distributedLock"); const lock = await acquireLock(`payment:update:${input.paymentId}`, 30_000); try { // Idempotency check @@ -184,76 +187,45 @@ export const paymentsRouter = router({ const TEMPORAL_URL = process.env.TEMPORAL_URL ?? "http://localhost:7233"; const TEMPORAL_NAMESPACE = process.env.TEMPORAL_NAMESPACE ?? "default"; - // Trigger Temporal ConfirmPaymentWorkflow (atomic: PostTB + ConfirmDB) + // Trigger Temporal ConfirmPaymentWorkflow; settlement remains pending + // until the workflow completes successfully. const workflowId = `confirm-payment-${input.paymentId}-${mojaloopTxId}`; - await fetch(`${TEMPORAL_URL}/api/v1/namespaces/${TEMPORAL_NAMESPACE}/workflows`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - workflow_type: { name: "ConfirmPaymentWorkflow" }, - workflow_id: workflowId, - task_queue: { name: "tradegateway-main" }, - input: { payloads: [{ data: Buffer.from(JSON.stringify({ - invoiceId: input.paymentId, - mojaloopTxId, - tbTxId: input.tbPendingTransferId ?? "", - method: "manual", - })).toString("base64") }] }, - }), - signal: AbortSignal.timeout(10_000), - }).catch((err) => { - // Temporal unavailable — fall back to direct DB update - console.error("[payments] Temporal unavailable, falling back to direct confirm:", err.message); - }); - - // Direct DB update (also executed by Temporal workflow — idempotent via ON CONFLICT) - const updated = await updatePayment(input.paymentId, { - status: "confirmed", - confirmedAt: new Date(), - mojalooopTransferId: mojaloopTxId, - }); - if (!updated) throw new TRPCError({ code: "NOT_FOUND" }); - - await updateDeclaration(updated.declarationId, { status: "payment_confirmed" }); - - await logAuditEvent({ - entityType: "payment", - entityId: input.paymentId, - action: "payment_confirmed", - actorId: ctx.user.id, - actorType: "system", - newState: { status: "confirmed", mojaloopTxId, workflowId }, - }); - - await createNotification({ - userId: updated.traderId, - type: "payment_confirmed", - title: "Payment Confirmed", - message: `Payment of ${updated.amount} ${updated.currency} confirmed. Your declaration is now queued for examination.`, - entityType: "payment", - entityId: input.paymentId, - }); - - await createUserNotification({ - userId: updated.traderId, - type: "payment_confirmed", - title: "Payment Confirmed ✓", - body: `Your payment of ${updated.amount} ${updated.currency} (Ref: ${updated.reference}) has been confirmed. Your declaration is now queued for examination.`, - declarationId: updated.declarationId, - }).catch(() => {}); + let temporalResponse: Response; + try { + temporalResponse = await fetch(`${TEMPORAL_URL}/api/v1/namespaces/${TEMPORAL_NAMESPACE}/workflows`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workflow_type: { name: "ConfirmPaymentWorkflow" }, + workflow_id: workflowId, + task_queue: { name: "tradegateway-main" }, + input: { payloads: [{ data: Buffer.from(JSON.stringify({ + invoiceId: input.paymentId, + mojaloopTxId, + tbTxId: input.tbPendingTransferId ?? "", + method: "manual", + })).toString("base64") }] }, + }), + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Temporal confirmation workflow unavailable" }); + } + if (!temporalResponse.ok) { + const detail = await temporalResponse.text().catch(() => temporalResponse.statusText); + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: `Temporal confirmation workflow unavailable (${temporalResponse.status}): ${detail}`, + }); + } - await emitPaymentCompleted({ + return { paymentId: input.paymentId, - declarationId: updated.declarationId, - traderId: updated.traderId, - amount: parseFloat(updated.amount ?? '0'), - currency: updated.currency ?? 'NGN', - mojalooopTransferId: updated.mojalooopTransferId ?? undefined, - }).catch(() => {}); + status: existing.status, + workflowId, + accepted: true, + }; - const result = { ...updated, workflowId }; - await setIdempotencyKey(idempotencyKey, result); - return result; } finally { await releaseLock(lock); } diff --git a/server/routers/rulesOfOrigin.ts b/server/routers/rulesOfOrigin.ts index 15ec4fde..79ca3473 100644 --- a/server/routers/rulesOfOrigin.ts +++ b/server/routers/rulesOfOrigin.ts @@ -158,6 +158,9 @@ export const rulesOfOriginRouter = router({ .from(originCertificates) .where(eq(originCertificates.id, input.id)); if (!cert) throw new TRPCError({ code: "NOT_FOUND" }); + if (cert.status !== "submitted" && cert.status !== "under_review") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Certificate is no longer reviewable" }); + } const [updated] = await (await getDb())! .update(originCertificates) .set({ diff --git a/server/routers/stream.ts b/server/routers/stream.ts index 0224f084..4354746a 100644 --- a/server/routers/stream.ts +++ b/server/routers/stream.ts @@ -29,34 +29,6 @@ async function fluvioAvailable(): Promise { } } -// Synthetic fallback events when the Fluvio consumer is unavailable -function generateFallbackEvents(limit: number, declarationId?: number): object[] { - const eventTypes = [ - "VESSEL_ARRIVED", "CONTAINER_GATE_IN", "INSPECTION_STARTED", - "CUSTOMS_HOLD_PLACED", "PAYMENT_RECEIVED", "CLEARANCE_PERMIT_ISSUED", - "CONTAINER_GATE_OUT", "VESSEL_DEPARTED", "AIS_POSITION_UPDATE", - ]; - const portCodes = ["GHTEM", "GHKSI", "GHKDI"]; - const severities = ["INFO", "INFO", "INFO", "WARNING", "CRITICAL"]; - const now = Date.now(); - return Array.from({ length: Math.min(limit, 20) }, (_, i) => ({ - event_id: `FALLBACK-${now - i * 3000}`, - event_type: eventTypes[i % eventTypes.length], - declaration_id: declarationId ?? (i % 3 === 0 ? 1000 + i : null), - ucr: declarationId ? `GH${String(declarationId).padStart(10, "0")}` : `GH${String(1000 + i).padStart(10, "0")}`, - container_ref: `GHCU${String(i * 1234567 % 9999999).padStart(7, "0")}`, - port_code: portCodes[i % portCodes.length], - location: "Tema Container Terminal", - actor: "PORT_OPERATOR", - message: `Simulated event ${i + 1} (fluvio-consumer offline)`, - severity: severities[i % severities.length], - timestamp: new Date(now - i * 3000).toISOString(), - partition: 0, - offset: 500 - i, - _fallback: true, - })); -} - export const streamRouter = router({ /** * Get recent cargo events from the ring buffer. @@ -70,11 +42,7 @@ export const streamRouter = router({ .query(async ({ input }) => { const available = await fluvioAvailable(); if (!available) { - return { - events: generateFallbackEvents(input.limit, input.declarationId), - count: Math.min(input.limit, 20), - source: "fallback", - }; + return { events: [], count: 0, source: "unavailable", unavailable: true, reason: "Fluvio consumer unavailable" }; } const params = new URLSearchParams({ limit: String(input.limit) }); if (input.declarationId) params.set("declarationId", String(input.declarationId)); @@ -120,8 +88,8 @@ export const streamRouter = router({ return { available: false, topic: "cargo-events", - mode: "fallback", - message: "Fluvio consumer is offline — using synthetic event fallback", + mode: "unavailable", + message: "Fluvio consumer is unavailable", }; } const res = await fetch(`${FLUVIO_SVC_URL}/health`, { diff --git a/server/routers/tradeFinance.ts b/server/routers/tradeFinance.ts index 1a9a8802..d51ed56b 100644 --- a/server/routers/tradeFinance.ts +++ b/server/routers/tradeFinance.ts @@ -30,7 +30,10 @@ export const tradeFinanceRouter = router({ hsCode: z.string(), incoterms: z.string().default("CIF"), })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + const applicantId = ["admin", "customs_officer", "finance", "oga_officer"].includes(ctx.user.role) + ? input.applicantId + : String(ctx.user.id); const res = await fetchWithResilience( `${TRADE_FINANCE_URL}/v1/letters-of-credit`, { @@ -38,7 +41,7 @@ export const tradeFinanceRouter = router({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ declaration_id: input.declarationId, - applicant_id: input.applicantId, + applicant_id: applicantId, applicant_name: input.applicantName, beneficiary_name: input.beneficiaryName, beneficiary_country: input.beneficiaryCountry, @@ -71,7 +74,10 @@ export const tradeFinanceRouter = router({ validDays: z.number().int().min(30).max(730).default(365), dutyAmount: z.number().min(0).default(0), })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + const traderId = ["admin", "customs_officer", "finance", "oga_officer"].includes(ctx.user.role) + ? input.traderId + : String(ctx.user.id); const res = await fetchWithResilience( `${TRADE_FINANCE_URL}/v1/bank-guarantees`, { @@ -79,7 +85,7 @@ export const tradeFinanceRouter = router({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ declaration_id: input.declarationId, - trader_id: input.traderId, + trader_id: traderId, issuing_bank: input.issuingBank, guarantee_type: input.guaranteeType, amount: input.amount, @@ -168,9 +174,12 @@ export const tradeFinanceRouter = router({ listBGByTrader: protectedProcedure .input(z.object({ traderId: z.string() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + const traderId = ["admin", "customs_officer", "finance", "oga_officer"].includes(ctx.user.role) + ? input.traderId + : String(ctx.user.id); const res = await fetchWithResilience( - `${TRADE_FINANCE_URL}/v1/bank-guarantees?trader_id=${input.traderId}`, + `${TRADE_FINANCE_URL}/v1/bank-guarantees?trader_id=${traderId}`, {}, "trade-finance" ); diff --git a/server/routers/valuation.ts b/server/routers/valuation.ts index b9cfaf34..587dbd29 100644 --- a/server/routers/valuation.ts +++ b/server/routers/valuation.ts @@ -82,7 +82,7 @@ export const valuationRouter = router({ })) .query(async ({ input }) => { const db = await getDb(); - if (!db) return { flagged: false, reason: "DB unavailable" }; + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Valuation database unavailable" }); const chapter = input.hsCode.substring(0, 4); const refs = await db diff --git a/server/routers/wazuh.ts b/server/routers/wazuh.ts index 13610664..d9ed7695 100644 --- a/server/routers/wazuh.ts +++ b/server/routers/wazuh.ts @@ -3,14 +3,10 @@ import { protectedProcedure, adminProcedure, router } from "../_core/trpc"; import { TRPCError } from "@trpc/server"; import { getDb } from "../db"; import { securityAlerts } from "../../drizzle/schema"; -import { desc, eq } from "drizzle-orm"; -import { publishEvent, TOPICS } from "../_core/kafka"; +import { eq } from "drizzle-orm"; const WAZUH_SVC_URL = process.env.WAZUH_SVC_URL ?? "http://wazuh-svc:8100"; -const DEMO_MODE = process.env.DEMO_MODE === "true"; - -async function callWazuh(path: string, method = "GET", body?: unknown): Promise { - if (DEMO_MODE) return null; +async function callWazuh(path: string, method = "GET", body?: unknown): Promise { try { const res = await fetch(`${WAZUH_SVC_URL}${path}`, { method, @@ -18,100 +14,44 @@ async function callWazuh(path: string, method = "GET", body?: unknown): Promi body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(5_000), }); - if (!res.ok) return null; + if (!res.ok) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: `Wazuh service returned ${res.status}` }); return res.json() as Promise; - } catch { - return null; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Wazuh service unavailable" }); } } export const wazuhRouter = router({ - // Get all security alerts — falls back to DB in demo mode + // Get all security alerts from Wazuh getAlerts: adminProcedure.query(async () => { - const live = await callWazuh<{ alerts: unknown[]; count: number }>("/alerts"); - if (live) return live; - // DB fallback - const db = await getDb(); - if (!db) return { alerts: [], count: 0 }; - const rows = await db.select().from(securityAlerts).orderBy(desc(securityAlerts.createdAt)).limit(100); - return { - alerts: rows.map(r => ({ - id: r.alertId, - severity: r.severity, - category: r.category, - title: r.title, - description: r.description, - source_ip: r.sourceIp, - target_service: r.targetService, - rule_id: r.ruleId, - rule_description: r.ruleDescription, - raw_event: r.rawEvent, - acknowledged: r.acknowledged, - acknowledged_by: r.acknowledgedBy, - acknowledged_at: r.acknowledgedAt, - resolved_at: r.resolvedAt, - timestamp: r.createdAt, - })), - count: rows.length, - }; + return callWazuh<{ alerts: unknown[]; count: number }>("/alerts"); }), - // Get all monitored agents — returns demo stub in fallback + // Get all monitored agents from Wazuh getAgents: adminProcedure.query(async () => { - const live = await callWazuh<{ agents: unknown[]; count: number }>("/agents"); - if (live) return live; - return { - agents: [ - { id: "001", name: "api-gateway-01", ip: "10.0.1.10", status: "active", os: "Ubuntu 22.04", version: "4.7.0", last_keepalive: new Date().toISOString() }, - { id: "002", name: "declaration-svc-01", ip: "10.0.1.11", status: "active", os: "Ubuntu 22.04", version: "4.7.0", last_keepalive: new Date().toISOString() }, - { id: "003", name: "postgresql-01", ip: "10.0.1.20", status: "active", os: "Ubuntu 22.04", version: "4.7.0", last_keepalive: new Date().toISOString() }, - { id: "004", name: "risk-engine-01", ip: "10.0.1.30", status: "active", os: "Ubuntu 22.04", version: "4.7.0", last_keepalive: new Date().toISOString() }, - { id: "005", name: "mojaloop-connector", ip: "10.0.1.40", status: "disconnected", os: "Ubuntu 22.04", version: "4.7.0", last_keepalive: new Date(Date.now() - 3600000).toISOString() }, - ], - count: 5, - }; + return callWazuh<{ agents: unknown[]; count: number }>("/agents"); }), - // List available playbooks — returns demo stub in fallback + // List available playbooks from Wazuh listPlaybooks: adminProcedure.query(async () => { - const live = await callWazuh<{ playbooks: unknown[] }>("/playbooks"); - if (live) return live; - return { - playbooks: [ - { id: "PB-001", name: "Block IP Address", description: "Automatically block a source IP at the WAF level", severity_threshold: "high", estimated_duration_seconds: 30 }, - { id: "PB-002", name: "Lock User Account", description: "Disable a user account and invalidate all active sessions", severity_threshold: "critical", estimated_duration_seconds: 10 }, - { id: "PB-003", name: "Isolate Service", description: "Remove a microservice from the service mesh to contain a breach", severity_threshold: "critical", estimated_duration_seconds: 60 }, - { id: "PB-004", name: "Rotate JWT Secret", description: "Rotate the JWT signing secret and force re-authentication for all users", severity_threshold: "high", estimated_duration_seconds: 120 }, - { id: "PB-005", name: "Capture Forensic Snapshot", description: "Take a memory dump and disk snapshot of the affected service for forensic analysis", severity_threshold: "medium", estimated_duration_seconds: 300 }, - ], - }; + return callWazuh<{ playbooks: unknown[] }>("/playbooks"); }), - // Trigger a response playbook — no-op in demo mode + // Trigger a response playbook through Wazuh triggerPlaybook: adminProcedure .input(z.object({ playbookId: z.string().min(1, "playbookId is required"), alertId: z.string().min(1, "alertId is required"), })) .mutation(async ({ input }) => { - const live = await callWazuh<{ + return callWazuh<{ id: string; playbook_id: string; alert_id: string; status: string; actions_taken: string[]; started_at: string; completed_at: string; }>("/playbooks/trigger", "POST", { playbook_id: input.playbookId, alert_id: input.alertId }); - if (live) return live; - // Demo stub - return { - id: `EXEC-${Date.now()}`, - playbook_id: input.playbookId, - alert_id: input.alertId, - status: "completed", - actions_taken: ["[DEMO] Action simulated — no real changes made"], - started_at: new Date().toISOString(), - completed_at: new Date().toISOString(), - }; }), - // Detect login anomalies — demo stub + // Detect login anomalies through Wazuh detectAnomaly: adminProcedure .input(z.object({ events: z.array(z.object({ @@ -123,7 +63,7 @@ export const wazuhRouter = router({ })), })) .mutation(async ({ input }) => { - const live = await callWazuh<{ + return callWazuh<{ detected: boolean; anomaly_type: string; severity: string; description: string; score: number; }>("/detect/anomaly", "POST", { events: input.events.map(e => ({ @@ -131,58 +71,13 @@ export const wazuhRouter = router({ country: e.country ?? "", timestamp: e.timestamp, success: e.success, })), }); - if (live) return live; - // Demo: simple heuristic - const failedCount = input.events.filter(e => !e.success).length; - const detected = failedCount >= 3; - const result = { - detected, - anomaly_type: detected ? "brute_force" : "none", - severity: detected ? "high" : "info", - description: detected - ? `[DEMO] ${failedCount} failed login attempts detected — possible brute force` - : "[DEMO] No anomaly detected in provided events", - score: detected ? 0.85 : 0.12, - }; - // Publish Kafka SECURITY_ALERT when anomaly detected (fire-and-forget) - if (detected) { - publishEvent(TOPICS.SECURITY_ALERT, { - eventType: "security.alert", - aggregateId: `wazuh-anomaly-${Date.now()}`, - payload: { - anomalyType: result.anomaly_type, - severity: result.severity, - description: result.description, - score: result.score, - eventCount: input.events.length, - failedCount, - }, - }).catch(() => {}); - } - return result; }), - // Get overall platform security score — DB-derived in demo mode + // Get overall platform security score from Wazuh getSecurityScore: protectedProcedure.query(async () => { - const live = await callWazuh<{ + return callWazuh<{ score: number; grade: string; unresolved_alerts: number; total_agents: number; computed_at: string; }>("/security-score"); - if (live) return live; - // DB fallback: derive score from unresolved alerts - const db = await getDb(); - if (!db) return { score: 78, grade: "B+", unresolved_alerts: 0, total_agents: 5, computed_at: new Date().toISOString() }; - const rows = await db.select().from(securityAlerts).where(eq(securityAlerts.acknowledged, false)); - const criticals = rows.filter(r => r.severity === "critical").length; - const highs = rows.filter(r => r.severity === "high").length; - const score = Math.max(0, 100 - criticals * 15 - highs * 8 - rows.length * 2); - const grade = score >= 90 ? "A" : score >= 80 ? "B+" : score >= 70 ? "B" : score >= 60 ? "C" : "D"; - return { - score, - grade, - unresolved_alerts: rows.length, - total_agents: 5, - computed_at: new Date().toISOString(), - }; }), // Acknowledge an alert @@ -190,7 +85,7 @@ export const wazuhRouter = router({ .input(z.object({ alertId: z.string() })) .mutation(async ({ input, ctx }) => { const db = await getDb(); - if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "DB unavailable" }); await db.update(securityAlerts) .set({ acknowledged: true, acknowledgedBy: ctx.user.id, acknowledgedAt: new Date() }) .where(eq(securityAlerts.alertId, input.alertId)); diff --git a/server/scheduled/documentVaultExpiry.ts b/server/scheduled/documentVaultExpiry.ts index c3558cf3..6a59e255 100644 --- a/server/scheduled/documentVaultExpiry.ts +++ b/server/scheduled/documentVaultExpiry.ts @@ -15,7 +15,7 @@ export async function documentVaultExpiryHandler(req: Request, res: Response) { try { const db = await getDb(); if (!db) { - return res.json({ ok: true, processed: 0, message: "DB unavailable" }); + return res.status(503).json({ ok: false, processed: 0, message: "DB unavailable" }); } const now = new Date(); @@ -90,7 +90,8 @@ export async function documentVaultExpiryHandler(req: Request, res: Response) { }); } catch (err) { const error = err instanceof Error ? err.message : String(err); - return res.status(500).json({ + return res.status(503).json({ + ok: false, error, context: { url: req.url, handler: "documentVaultExpiry" }, timestamp: new Date().toISOString(), diff --git a/server/scheduled/slaBreachEscalation.ts b/server/scheduled/slaBreachEscalation.ts index d39063b3..c6e11c52 100644 --- a/server/scheduled/slaBreachEscalation.ts +++ b/server/scheduled/slaBreachEscalation.ts @@ -12,7 +12,7 @@ export async function slaBreachEscalationHandler(req: Request, res: Response) { try { const db = await getDb(); if (!db) { - return res.json({ ok: true, processed: 0, message: "DB unavailable" }); + return res.status(503).json({ ok: false, processed: 0, message: "DB unavailable" }); } const now = new Date(); @@ -64,7 +64,8 @@ export async function slaBreachEscalationHandler(req: Request, res: Response) { }); } catch (err) { const error = err instanceof Error ? err.message : String(err); - return res.status(500).json({ + return res.status(503).json({ + ok: false, error, context: { url: req.url, handler: "slaBreachEscalation" }, timestamp: new Date().toISOString(), diff --git a/server/v77.test.ts b/server/v77.test.ts index b4a8a74d..5fb91ce7 100644 --- a/server/v77.test.ts +++ b/server/v77.test.ts @@ -160,11 +160,12 @@ describe("TB-09 to TB-12: tRPC ledger procedures", () => { expect(ledgerSrc).toContain("postBondDeposit:"); }); - it("TB-09: postBondDeposit has offline stub (createLedgerEntry fallback)", () => { + it("TB-09: postBondDeposit fails closed when the bridge is unavailable", () => { const idx = ledgerSrc.indexOf("postBondDeposit:"); const window = ledgerSrc.slice(idx, idx + 1500); - expect(window).toContain("createLedgerEntry"); - expect(window).toContain("offline-stub"); + expect(window).toContain("SERVICE_UNAVAILABLE"); + expect(window).not.toContain("createLedgerEntry"); + expect(window).not.toContain("offline-stub"); }); it("TB-09: postBondDeposit calls /bond/deposit on bridge", () => { @@ -177,11 +178,12 @@ describe("TB-09 to TB-12: tRPC ledger procedures", () => { expect(ledgerSrc).toContain("releaseBond:"); }); - it("TB-10: releaseBond has offline stub", () => { + it("TB-10: releaseBond fails closed when the bridge is unavailable", () => { const idx = ledgerSrc.indexOf("releaseBond:"); const window = ledgerSrc.slice(idx, idx + 1500); - expect(window).toContain("createLedgerEntry"); - expect(window).toContain("offline-stub"); + expect(window).toContain("SERVICE_UNAVAILABLE"); + expect(window).not.toContain("createLedgerEntry"); + expect(window).not.toContain("offline-stub"); }); it("TB-10: releaseBond calls /bond/release on bridge", () => { @@ -194,11 +196,12 @@ describe("TB-09 to TB-12: tRPC ledger procedures", () => { expect(ledgerSrc).toContain("postPenalty:"); }); - it("TB-11: postPenalty has offline stub", () => { + it("TB-11: postPenalty fails closed when the bridge is unavailable", () => { const idx = ledgerSrc.indexOf("postPenalty:"); const window = ledgerSrc.slice(idx, idx + 1500); - expect(window).toContain("createLedgerEntry"); - expect(window).toContain("offline-stub"); + expect(window).toContain("SERVICE_UNAVAILABLE"); + expect(window).not.toContain("createLedgerEntry"); + expect(window).not.toContain("offline-stub"); }); it("TB-11: postPenalty calls /penalty on bridge", () => { @@ -211,11 +214,12 @@ describe("TB-09 to TB-12: tRPC ledger procedures", () => { expect(ledgerSrc).toContain("postTransitGuarantee:"); }); - it("TB-12: postTransitGuarantee has offline stub", () => { + it("TB-12: postTransitGuarantee fails closed when the bridge is unavailable", () => { const idx = ledgerSrc.indexOf("postTransitGuarantee:"); const window = ledgerSrc.slice(idx, idx + 1500); - expect(window).toContain("createLedgerEntry"); - expect(window).toContain("offline-stub"); + expect(window).toContain("SERVICE_UNAVAILABLE"); + expect(window).not.toContain("createLedgerEntry"); + expect(window).not.toContain("offline-stub"); }); it("TB-12: postTransitGuarantee calls /transit-guarantee on bridge", () => { diff --git a/server/v78.test.ts b/server/v78.test.ts index 3b3f7d4c..fdabde97 100644 --- a/server/v78.test.ts +++ b/server/v78.test.ts @@ -255,18 +255,16 @@ describe("bondedWarehouse.ts: Kafka publish for deposit and release", () => { describe("wazuh.ts: Kafka publish for security alerts", () => { const wazuhTs = readText("server/routers/wazuh.ts"); - it("imports publishEvent from kafka.ts", () => { - expect(wazuhTs).toContain("publishEvent"); + it("does not publish fabricated anomaly events when Wazuh is unavailable", () => { + expect(wazuhTs).not.toContain("publishEvent"); }); - it("publishes SECURITY_ALERT in detectAnomaly", () => { - expect(wazuhTs).toContain("SECURITY_ALERT"); + it("does not synthesize SECURITY_ALERT events in the Wazuh fallback", () => { + expect(wazuhTs).not.toContain("SECURITY_ALERT"); }); - it("SECURITY_ALERT uses DomainEvent aggregateId", () => { - const idx = wazuhTs.indexOf("SECURITY_ALERT"); - const window = wazuhTs.slice(idx, idx + 500); - expect(window).toContain("aggregateId"); + it("does not retain the removed synthetic SECURITY_ALERT payload", () => { + expect(wazuhTs).not.toContain("aggregateId"); }); }); diff --git a/server/webhooks/cep.ts b/server/webhooks/cep.ts index 1d48f6b9..bc73134f 100644 --- a/server/webhooks/cep.ts +++ b/server/webhooks/cep.ts @@ -8,7 +8,7 @@ * Security: * - HMAC-SHA256 signature verification via X-CEP-Signature header. * - Shared secret configured via CEP_WEBHOOK_SECRET environment variable. - * - Falls back to a dev-only default when the env var is absent. + * - Requests without a configured secret are rejected. * * Payload schema follows the WCO Risk Management Compendium Vol 1 alert format. * The Flink job should POST to this endpoint immediately after a CEP pattern fires. @@ -23,14 +23,12 @@ import express, { Request, Response } from "express"; import crypto from "crypto"; import { getPool } from "../db"; import { notifyOwner } from "../_core/notification"; - -const CEP_WEBHOOK_SECRET = - process.env.CEP_WEBHOOK_SECRET ?? "tradegateway-cep-webhook-secret-dev"; +import { getWebhookSecret } from "../_core/webhookSecretsValidator"; // ─── Signature verification ─────────────────────────────────────────────────── -function verifySignature(rawBody: string, signature: string): boolean { +function verifySignature(rawBody: string, signature: string, secret: string): boolean { const expected = crypto - .createHmac("sha256", CEP_WEBHOOK_SECRET) + .createHmac("sha256", secret) .update(rawBody) .digest("hex"); const provided = signature.replace(/^sha256=/, ""); @@ -91,17 +89,15 @@ export function registerCepWebhookRoute(app: express.Application) { // ── 1. Signature verification ────────────────────────────────────────── const rawBody = req.body instanceof Buffer ? req.body.toString("utf8") : ""; const signature = String(req.headers["x-cep-signature"] ?? ""); - - // In development (no secret configured) we skip signature check to ease testing. - const isDev = process.env.NODE_ENV !== "production"; - if (!isDev && signature) { - if (!verifySignature(rawBody, signature)) { - res.status(401).json({ error: "Invalid X-CEP-Signature" }); - return; - } - } else if (!isDev && !signature) { - // Production requires the header - res.status(401).json({ error: "Missing X-CEP-Signature header" }); + let secret: string; + try { + secret = getWebhookSecret("CEP_WEBHOOK_SECRET"); + } catch { + res.status(503).json({ error: "Webhook authentication unavailable" }); + return; + } + if (!signature || !verifySignature(rawBody, signature, secret)) { + res.status(401).json({ error: "Invalid X-CEP-Signature" }); return; } diff --git a/server/webhooks/mojaloop.ts b/server/webhooks/mojaloop.ts new file mode 100644 index 00000000..46d36030 --- /dev/null +++ b/server/webhooks/mojaloop.ts @@ -0,0 +1,105 @@ +import express, { type Request, type Response } from "express"; +import crypto from "crypto"; +import { getWebhookSecret } from "../_core/webhookSecretsValidator"; +import { + createLedgerEntry, + getMojaloopTransactionByTransferId, + logAuditEvent, + updateMojaloopTransaction, +} from "../db"; + +function verifySignature(rawBody: Buffer, signature: string, secret: string): boolean { + const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); + const provided = signature.replace(/^sha256=/, ""); + try { + return crypto.timingSafeEqual(Buffer.from(provided, "hex"), Buffer.from(expected, "hex")); + } catch { + return false; + } +} + +export function registerMojaloopWebhookRoute(app: express.Application): void { + app.post( + "/api/webhooks/mojaloop", + express.raw({ type: "application/json" }), + async (req: Request, res: Response) => { + let secret: string; + try { + secret = getWebhookSecret("MOJALOOP_WEBHOOK_SECRET"); + } catch { + return res.status(503).json({ error: "Webhook authentication unavailable" }); + } + const rawBody = req.body instanceof Buffer ? req.body : Buffer.from(""); + const signature = String(req.headers["x-mojaloop-signature"] ?? ""); + if (!signature || !verifySignature(rawBody, signature, secret)) { + return res.status(401).json({ error: "Invalid signature" }); + } + + let input: { + transferId: string; + transferState: "RECEIVED" | "RESERVED" | "COMMITTED" | "ABORTED"; + fulfilment?: string; + completedTimestamp?: string; + errorInformation?: { errorCode: string; errorDescription: string }; + }; + try { + input = JSON.parse(rawBody.toString("utf8")); + } catch { + return res.status(400).json({ error: "Invalid JSON payload" }); + } + if (!input.transferId || !input.transferState) { + return res.status(400).json({ error: "Missing transferId or transferState" }); + } + + const tx = await getMojaloopTransactionByTransferId(input.transferId); + if (!tx) return res.status(404).json({ error: "Transfer not found" }); + + const updateData: Record = { + status: input.transferState, + webhookPayload: input, + }; + if (input.transferState === "COMMITTED") { + updateData.fulfilment = input.fulfilment ?? null; + updateData.committedAt = input.completedTimestamp ? new Date(input.completedTimestamp) : new Date(); + const tbTransferId = crypto.randomUUID().replace(/-/g, "").slice(0, 32).padStart(32, "0"); + await createLedgerEntry({ + tbTransferId, + debitAccountId: "0000000000000002", + creditAccountId: "0000000000000001", + amountMinorUnits: Math.round(Number(tx.amount) * 100), + currency: tx.currency, + ledger: 1, + entryType: "duty_payment", + status: "posted", + declarationId: tx.declarationId ?? undefined, + mojaloopTransferId: input.transferId, + reference: `DUTY-${tx.declarationId ?? "N/A"}`, + description: `Duty payment settled via Mojaloop webhook (${input.transferId})`, + postedAt: new Date(), + }); + await logAuditEvent({ + entityType: "payment", + entityId: tx.id, + action: "mojaloop_webhook_committed", + actorId: tx.initiatedBy, + actorType: "system", + newState: { transferId: input.transferId, status: "COMMITTED" }, + }); + } + if (input.transferState === "ABORTED") { + updateData.abortedAt = new Date(); + updateData.failureReason = input.errorInformation?.errorDescription ?? "Transfer aborted"; + await logAuditEvent({ + entityType: "payment", + entityId: tx.id, + action: "mojaloop_webhook_aborted", + actorId: tx.initiatedBy, + actorType: "system", + newState: { transferId: input.transferId, status: "ABORTED", error: input.errorInformation }, + }); + } + await updateMojaloopTransaction(input.transferId, updateData as never); + return res.json({ success: true, transferId: input.transferId, newStatus: input.transferState }); + }, + ); +} diff --git a/server/webhooks/oga.ts b/server/webhooks/oga.ts index 3f4258ff..9b26e778 100644 --- a/server/webhooks/oga.ts +++ b/server/webhooks/oga.ts @@ -13,13 +13,12 @@ import { getDb } from "../db"; import { ogaPermits, declarations } from "../../drizzle/schema"; import { eq, and } from "drizzle-orm"; import { notifyOwner } from "../_core/notification"; - -const OGA_WEBHOOK_SECRET = process.env.OGA_WEBHOOK_SECRET ?? "tradegateway-oga-webhook-secret-dev"; +import { getWebhookSecret } from "../_core/webhookSecretsValidator"; // Verify HMAC-SHA256 signature from OGA system -function verifySignature(payload: string, signature: string): boolean { +function verifySignature(payload: string, signature: string, secret: string): boolean { const expected = crypto - .createHmac("sha256", OGA_WEBHOOK_SECRET) + .createHmac("sha256", secret) .update(payload) .digest("hex"); try { @@ -58,12 +57,16 @@ export function registerOgaWebhookRoute(app: express.Application) { "/api/webhooks/oga", express.raw({ type: "application/json" }), async (req: Request, res: Response) => { + let secret: string; + try { + secret = getWebhookSecret("OGA_WEBHOOK_SECRET"); + } catch { + return res.status(503).json({ error: "Webhook authentication unavailable" }); + } const rawBody = req.body instanceof Buffer ? req.body.toString("utf8") : JSON.stringify(req.body); const signature = (req.headers["x-oga-signature"] as string) ?? ""; - // In production: enforce signature verification - // In dev: skip if no signature provided (allows testing without HMAC) - if (signature && !verifySignature(rawBody, signature)) { + if (!signature || !verifySignature(rawBody, signature, secret)) { return res.status(401).json({ error: "Invalid signature" }); } diff --git a/server/webhooks/sanctions.ts b/server/webhooks/sanctions.ts index 9334a7ed..0af3199b 100644 --- a/server/webhooks/sanctions.ts +++ b/server/webhooks/sanctions.ts @@ -21,8 +21,8 @@ import { } from "../db"; import type { users } from "../../drizzle/schema"; import { notifyOwner } from "../_core/notification"; - -const WEBHOOK_SECRET = process.env.SANCTIONS_WEBHOOK_SECRET || ""; +import crypto from "crypto"; +import { getWebhookSecret } from "../_core/webhookSecretsValidator"; interface SanctionsHitPayload { declarationId?: number; @@ -42,7 +42,20 @@ export function registerSanctionsWebhookRoute(app: Express): void { async (req: Request, res: Response) => { // ── 1. Validate shared secret ────────────────────────────────────────── const providedSecret = req.headers["x-sanctions-secret"] as string; - if (WEBHOOK_SECRET && providedSecret !== WEBHOOK_SECRET) { + let webhookSecret: string; + try { + webhookSecret = getWebhookSecret("SANCTIONS_WEBHOOK_SECRET"); + } catch { + res.status(503).json({ error: "Webhook authentication unavailable" }); + return; + } + let validSecret = false; + try { + validSecret = crypto.timingSafeEqual(Buffer.from(providedSecret ?? ""), Buffer.from(webhookSecret)); + } catch { + validSecret = false; + } + if (!providedSecret || !validSecret) { console.warn( "[SanctionsWebhook] Rejected: invalid secret from", req.ip diff --git a/shared/const.ts b/shared/const.ts index 112ffc59..1d8fa134 100644 --- a/shared/const.ts +++ b/shared/const.ts @@ -1,5 +1,6 @@ export const COOKIE_NAME = "app_session_id"; export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365; +export const SEVEN_DAYS_MS = 1000 * 60 * 60 * 24 * 7; export const AXIOS_TIMEOUT_MS = 30_000; export const UNAUTHED_ERR_MSG = 'Please login (10001)'; export const NOT_ADMIN_ERR_MSG = 'You do not have required permission (10002)'; From 2229ea6383b469b4adc0485ceace0444073d98bb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:50:52 +0000 Subject: [PATCH 2/5] fix: restore durable security fallbacks Co-Authored-By: Patrick Munis --- server/routers/ledger.ts | 4 +-- server/routers/wazuh.ts | 70 ++++++++++++++++++++++++++++++++++--- server/v78.test.ts | 18 ---------- server/wazuh.test.ts | 40 ++++++++++++++++++++- server/webhooks/mojaloop.ts | 62 +++++++++++++++++++++++--------- 5 files changed, 152 insertions(+), 42 deletions(-) diff --git a/server/routers/ledger.ts b/server/routers/ledger.ts index 26a9998f..722ee5f8 100644 --- a/server/routers/ledger.ts +++ b/server/routers/ledger.ts @@ -30,7 +30,7 @@ import { const TB_BRIDGE_URL = process.env.TB_BRIDGE_URL || "http://tigerbeetle-bridge:8093"; const PAYMENT_RISK_URL = process.env.PAYMENT_RISK_URL || "http://localhost:8092"; -async function tbBridgeAvailable(): Promise { +export async function tbBridgeAvailable(): Promise { try { const res = await fetch(`${TB_BRIDGE_URL}/health`, { signal: AbortSignal.timeout(3_000) }); return res.ok; @@ -48,7 +48,7 @@ async function riskScorerAvailable(): Promise { } } -async function tbFetch(path: string, options?: RequestInit): Promise { +export async function tbFetch(path: string, options?: RequestInit): Promise { const res = await fetch(`${TB_BRIDGE_URL}${path}`, { ...options, headers: { "Content-Type": "application/json", ...(options?.headers ?? {}) }, diff --git a/server/routers/wazuh.ts b/server/routers/wazuh.ts index d9ed7695..f03613a7 100644 --- a/server/routers/wazuh.ts +++ b/server/routers/wazuh.ts @@ -3,7 +3,7 @@ import { protectedProcedure, adminProcedure, router } from "../_core/trpc"; import { TRPCError } from "@trpc/server"; import { getDb } from "../db"; import { securityAlerts } from "../../drizzle/schema"; -import { eq } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; const WAZUH_SVC_URL = process.env.WAZUH_SVC_URL ?? "http://wazuh-svc:8100"; async function callWazuh(path: string, method = "GET", body?: unknown): Promise { @@ -25,7 +25,43 @@ async function callWazuh(path: string, method = "GET", body?: unknown): Promi export const wazuhRouter = router({ // Get all security alerts from Wazuh getAlerts: adminProcedure.query(async () => { - return callWazuh<{ alerts: unknown[]; count: number }>("/alerts"); + try { + const live = await callWazuh<{ alerts: unknown[]; count: number }>("/alerts"); + return { ...live, source: "wazuh" as const }; + } catch { + const db = await getDb(); + if (!db) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Wazuh and security-alert database are unavailable" }); + } + try { + const rows = await db.select().from(securityAlerts) + .orderBy(desc(securityAlerts.createdAt)) + .limit(100); + return { + alerts: rows.map(r => ({ + id: r.alertId, + severity: r.severity, + category: r.category, + title: r.title, + description: r.description, + source_ip: r.sourceIp, + target_service: r.targetService, + rule_id: r.ruleId, + rule_description: r.ruleDescription, + raw_event: r.rawEvent, + acknowledged: r.acknowledged, + acknowledged_by: r.acknowledgedBy, + acknowledged_at: r.acknowledgedAt, + resolved_at: r.resolvedAt, + timestamp: r.createdAt, + })), + count: rows.length, + source: "database" as const, + }; + } catch { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Security-alert database is unavailable" }); + } + } }), // Get all monitored agents from Wazuh @@ -75,9 +111,33 @@ export const wazuhRouter = router({ // Get overall platform security score from Wazuh getSecurityScore: protectedProcedure.query(async () => { - return callWazuh<{ - score: number; grade: string; unresolved_alerts: number; total_agents: number; computed_at: string; - }>("/security-score"); + try { + const live = await callWazuh<{ + score: number; grade: string; unresolved_alerts: number; total_agents: number; computed_at: string; + }>("/security-score"); + return { ...live, source: "wazuh" as const }; + } catch { + const db = await getDb(); + if (!db) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Wazuh and security-alert database are unavailable" }); + } + try { + const rows = await db.select().from(securityAlerts).where(eq(securityAlerts.acknowledged, false)); + const criticals = rows.filter(r => r.severity === "critical").length; + const highs = rows.filter(r => r.severity === "high").length; + const score = Math.max(0, 100 - criticals * 15 - highs * 8 - rows.length * 2); + const grade = score >= 90 ? "A" : score >= 80 ? "B+" : score >= 70 ? "B" : score >= 60 ? "C" : "D"; + return { + score, + grade, + unresolved_alerts: rows.length, + computed_at: new Date().toISOString(), + source: "database" as const, + }; + } catch { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Security-alert database is unavailable" }); + } + } }), // Acknowledge an alert diff --git a/server/v78.test.ts b/server/v78.test.ts index fdabde97..65d2124f 100644 --- a/server/v78.test.ts +++ b/server/v78.test.ts @@ -250,24 +250,6 @@ describe("bondedWarehouse.ts: Kafka publish for deposit and release", () => { }); }); -// ─── 17. wazuh.ts: Kafka publish for security alerts ──────────────────────── - -describe("wazuh.ts: Kafka publish for security alerts", () => { - const wazuhTs = readText("server/routers/wazuh.ts"); - - it("does not publish fabricated anomaly events when Wazuh is unavailable", () => { - expect(wazuhTs).not.toContain("publishEvent"); - }); - - it("does not synthesize SECURITY_ALERT events in the Wazuh fallback", () => { - expect(wazuhTs).not.toContain("SECURITY_ALERT"); - }); - - it("does not retain the removed synthetic SECURITY_ALERT payload", () => { - expect(wazuhTs).not.toContain("aggregateId"); - }); -}); - // ─── 18. insiderThreat.ts: Kafka publish on threat detection ───────────────── describe("insiderThreat.ts: Kafka publish on insider threat detection", () => { diff --git a/server/wazuh.test.ts b/server/wazuh.test.ts index ba2fbc26..6dfe5e3b 100644 --- a/server/wazuh.test.ts +++ b/server/wazuh.test.ts @@ -3,7 +3,7 @@ * All procedures are adminProcedure. * External Wazuh API calls will fail in test env — we verify graceful handling. */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { appRouter } from "./routers"; import type { TrpcContext } from "./_core/context"; @@ -113,6 +113,19 @@ describe("wazuh.triggerPlaybook", () => { expect(result).toBeDefined(); }); + it("does not report a completed playbook when Wazuh is unavailable", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Wazuh unavailable")); + try { + const caller = appRouter.createCaller(makeCtx({ role: "admin" })); + await expect(caller.wazuh.triggerPlaybook({ + playbookId: "PB-ISOLATE-HOST", + alertId: "ALERT-001", + })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + } finally { + fetchMock.mockRestore(); + } + }); + it("throws for non-admin role", async () => { const caller = appRouter.createCaller(makeCtx({ role: "customs_officer" })); await expect( @@ -147,6 +160,31 @@ describe("wazuh.detectAnomaly", () => { expect(result).toBeDefined(); }); + it("fails closed without returning a fabricated anomaly when Wazuh is unavailable", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Wazuh unavailable")); + try { + const caller = appRouter.createCaller(makeCtx({ role: "admin" })); + await expect(caller.wazuh.detectAnomaly({ + events: [ + { userId: "user-001", ipAddress: "192.168.1.100", timestamp: new Date().toISOString(), success: false }, + ], + })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + } finally { + fetchMock.mockRestore(); + } + }); + + it("fails closed for an empty anomaly request when Wazuh is unavailable", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Wazuh unavailable")); + try { + const caller = appRouter.createCaller(makeCtx({ role: "admin" })); + await expect(caller.wazuh.detectAnomaly({ events: [] })) + .rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + } finally { + fetchMock.mockRestore(); + } + }); + it("throws or returns for empty events array", async () => { const caller = appRouter.createCaller(makeCtx({ role: "admin" })); const result = await caller.wazuh.detectAnomaly({ events: [] }).catch(e => e); diff --git a/server/webhooks/mojaloop.ts b/server/webhooks/mojaloop.ts index 46d36030..147148ee 100644 --- a/server/webhooks/mojaloop.ts +++ b/server/webhooks/mojaloop.ts @@ -7,6 +7,8 @@ import { logAuditEvent, updateMojaloopTransaction, } from "../db"; +import { getOrProvisionTraderAccount, SYSTEM_ACCOUNTS } from "../_core/paymentAccountProvisioner"; +import { tbBridgeAvailable, tbFetch } from "../routers/ledger"; function verifySignature(rawBody: Buffer, signature: string, secret: string): boolean { const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); @@ -61,22 +63,6 @@ export function registerMojaloopWebhookRoute(app: express.Application): void { if (input.transferState === "COMMITTED") { updateData.fulfilment = input.fulfilment ?? null; updateData.committedAt = input.completedTimestamp ? new Date(input.completedTimestamp) : new Date(); - const tbTransferId = crypto.randomUUID().replace(/-/g, "").slice(0, 32).padStart(32, "0"); - await createLedgerEntry({ - tbTransferId, - debitAccountId: "0000000000000002", - creditAccountId: "0000000000000001", - amountMinorUnits: Math.round(Number(tx.amount) * 100), - currency: tx.currency, - ledger: 1, - entryType: "duty_payment", - status: "posted", - declarationId: tx.declarationId ?? undefined, - mojaloopTransferId: input.transferId, - reference: `DUTY-${tx.declarationId ?? "N/A"}`, - description: `Duty payment settled via Mojaloop webhook (${input.transferId})`, - postedAt: new Date(), - }); await logAuditEvent({ entityType: "payment", entityId: tx.id, @@ -99,6 +85,50 @@ export function registerMojaloopWebhookRoute(app: express.Application): void { }); } await updateMojaloopTransaction(input.transferId, updateData as never); + + if (input.transferState === "COMMITTED") { + try { + if (!(await tbBridgeAvailable())) { + throw new Error("TigerBeetle bridge is unavailable"); + } + const debitAccountId = await getOrProvisionTraderAccount(tx.initiatedBy, tx.currency); + const bridgeTransfer = await tbFetch<{ id: string }>("/api/ledger/transfers", { + method: "POST", + body: JSON.stringify({ + debitAccountId, + creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, + amount: String(tx.amount), + currency: tx.currency, + ledger: 1, + reference: `DUTY-${tx.declarationId ?? "N/A"}`, + description: `Duty payment settled via Mojaloop webhook (${input.transferId})`, + metadata: { mojaloopTransferId: input.transferId }, + }), + }); + await createLedgerEntry({ + tbTransferId: bridgeTransfer.id, + debitAccountId, + creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, + amountMinorUnits: Math.round(Number(tx.amount) * 100), + currency: tx.currency, + ledger: 1, + entryType: "duty_payment", + status: "posted", + declarationId: tx.declarationId ?? undefined, + mojaloopTransferId: input.transferId, + reference: `DUTY-${tx.declarationId ?? "N/A"}`, + description: `Duty payment settled via Mojaloop webhook (${input.transferId})`, + postedAt: new Date(), + }); + } catch (error) { + console.error("[Mojaloop] TigerBeetle settlement unavailable:", error); + return res.status(503).json({ + error: "Ledger settlement unavailable; webhook will be retried", + transferId: input.transferId, + }); + } + } + return res.json({ success: true, transferId: input.transferId, newStatus: input.transferState }); }, ); From 403cfc84e8f1672f564edd0cd54a11d2664e4d87 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:07:16 +0000 Subject: [PATCH 3/5] fix: make Mojaloop settlement idempotent Co-Authored-By: Patrick Munis --- server/db.ts | 43 ++++++++ server/mojaloop.webhook.test.ts | 169 ++++++++++++++++++++++++++++++++ server/webhooks/mojaloop.ts | 127 ++++++++++++++++++------ 3 files changed, 309 insertions(+), 30 deletions(-) create mode 100644 server/mojaloop.webhook.test.ts diff --git a/server/db.ts b/server/db.ts index f3105587..b7af6675 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1168,6 +1168,49 @@ export async function getLedgerEntriesByPayment(paymentId: number) { .orderBy(desc(tigerBeetleLedgerEntries.createdAt)); } +export async function getLedgerEntryByMojaloopTransferId(mojaloopTransferId: string) { + const db = await getDb(); + if (!db) return undefined; + const { tigerBeetleLedgerEntries } = await import("../drizzle/schema"); + const result = await db.select().from(tigerBeetleLedgerEntries) + .where(eq(tigerBeetleLedgerEntries.mojaloopTransferId, mojaloopTransferId)) + .limit(1); + return result[0] ?? undefined; +} + +export async function claimPaymentIdempotencyKey(data: { + keyHash: string; + transferId: string; + responseSnapshot: unknown; + expiresAt: Date; +}) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const { paymentIdempotencyKeys } = await import("../drizzle/schema"); + const result = await db.insert(paymentIdempotencyKeys) + .values(data) + .onConflictDoNothing() + .returning(); + return result[0] ?? undefined; +} + +export async function releasePaymentIdempotencyKey(keyHash: string) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const { paymentIdempotencyKeys } = await import("../drizzle/schema"); + await db.delete(paymentIdempotencyKeys) + .where(eq(paymentIdempotencyKeys.keyHash, keyHash)); +} + +export async function completePaymentIdempotencyKey(keyHash: string, responseSnapshot: unknown) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const { paymentIdempotencyKeys } = await import("../drizzle/schema"); + await db.update(paymentIdempotencyKeys) + .set({ responseSnapshot }) + .where(eq(paymentIdempotencyKeys.keyHash, keyHash)); +} + export async function getRecentLedgerEntries(limit = 50) { const db = await getDb(); if (!db) return []; diff --git a/server/mojaloop.webhook.test.ts b/server/mojaloop.webhook.test.ts new file mode 100644 index 00000000..7262937e --- /dev/null +++ b/server/mojaloop.webhook.test.ts @@ -0,0 +1,169 @@ +import crypto from "node:crypto"; +import { createServer } from "node:http"; +import express from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + claimPaymentIdempotencyKey, + completePaymentIdempotencyKey, + createLedgerEntry, + getLedgerEntryByMojaloopTransferId, + getMojaloopTransactionByTransferId, + logAuditEvent, + releasePaymentIdempotencyKey, + updateMojaloopTransaction, +} from "./db"; +import { + getOrProvisionTraderAccount, +} from "./_core/paymentAccountProvisioner"; +import { tbBridgeAvailable, tbFetch } from "./routers/ledger"; +import { registerMojaloopWebhookRoute } from "./webhooks/mojaloop"; + +vi.mock("./db", () => ({ + claimPaymentIdempotencyKey: vi.fn(), + completePaymentIdempotencyKey: vi.fn(), + createLedgerEntry: vi.fn(), + getLedgerEntryByMojaloopTransferId: vi.fn(), + getMojaloopTransactionByTransferId: vi.fn(), + logAuditEvent: vi.fn(), + releasePaymentIdempotencyKey: vi.fn(), + updateMojaloopTransaction: vi.fn(), +})); + +vi.mock("./_core/paymentAccountProvisioner", () => ({ + getOrProvisionTraderAccount: vi.fn(), + SYSTEM_ACCOUNTS: { NCS_REVENUE: "ncs-revenue-account" }, +})); + +vi.mock("./routers/ledger", () => ({ + tbBridgeAvailable: vi.fn(), + tbFetch: vi.fn(), +})); + +const webhookSecret = "mojaloop-test-secret-012345678901234567890"; +const transferId = "MJL-WEBHOOK-001"; + +function signedBody(payload: unknown): { body: string; signature: string } { + const body = JSON.stringify(payload); + const signature = crypto.createHmac("sha256", webhookSecret).update(body).digest("hex"); + return { body, signature }; +} + +async function postWebhook(payload: unknown): Promise { + const app = express(); + registerMojaloopWebhookRoute(app); + const server = createServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Test server did not bind"); + const { body, signature } = signedBody(payload); + return await fetch(`http://127.0.0.1:${address.port}/api/webhooks/mojaloop`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Mojaloop-Signature": signature, + }, + body, + }); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +} + +const transaction = { + id: 42, + transferId, + initiatedBy: 9702, + currency: "GHS", + amount: "500.00", + declarationId: 7, + status: "PENDING", +}; + +const committedPayload = { + transferId, + transferState: "COMMITTED", + fulfilment: "fulfilment-001", + completedTimestamp: "2026-01-01T00:00:00.000Z", +}; + +beforeEach(() => { + process.env.MOJALOOP_WEBHOOK_SECRET = webhookSecret; + vi.clearAllMocks(); + vi.mocked(getMojaloopTransactionByTransferId).mockResolvedValue(transaction as never); + vi.mocked(getLedgerEntryByMojaloopTransferId).mockResolvedValue(undefined); + vi.mocked(claimPaymentIdempotencyKey).mockResolvedValue({ id: 1 } as never); + vi.mocked(completePaymentIdempotencyKey).mockResolvedValue(undefined); + vi.mocked(createLedgerEntry).mockResolvedValue({ id: 1 } as never); + vi.mocked(getOrProvisionTraderAccount).mockResolvedValue("trader-9702"); + vi.mocked(tbFetch).mockResolvedValue({ id: "tb-transfer-001" }); + vi.mocked(updateMojaloopTransaction).mockResolvedValue(transaction as never); + vi.mocked(logAuditEvent).mockResolvedValue(undefined); + vi.mocked(releasePaymentIdempotencyKey).mockResolvedValue(undefined); +}); + +afterEach(() => { + delete process.env.MOJALOOP_WEBHOOK_SECRET; +}); + +describe("Mojaloop webhook settlement", () => { + it("leaves the transaction unsettled when TigerBeetle is unavailable", async () => { + vi.mocked(tbBridgeAvailable).mockResolvedValue(false); + + const response = await postWebhook(committedPayload); + + expect(response.status).toBe(503); + expect(createLedgerEntry).not.toHaveBeenCalled(); + expect(updateMojaloopTransaction).not.toHaveBeenCalled(); + expect(logAuditEvent).not.toHaveBeenCalled(); + expect(releasePaymentIdempotencyKey).toHaveBeenCalledOnce(); + }); + + it("uses the durable claim and ledger marker to prevent duplicate settlement", async () => { + vi.mocked(tbBridgeAvailable).mockResolvedValue(true); + let persistedLedger: unknown; + let currentTransaction = { ...transaction }; + vi.mocked(getMojaloopTransactionByTransferId).mockImplementation(async () => currentTransaction as never); + vi.mocked(updateMojaloopTransaction).mockImplementation(async (_transferId, data) => { + currentTransaction = { ...currentTransaction, ...data } as typeof currentTransaction; + return currentTransaction as never; + }); + vi.mocked(createLedgerEntry).mockImplementation(async () => { + persistedLedger = { tbTransferId: "tb-transfer-001", mojaloopTransferId: transferId }; + return persistedLedger as never; + }); + vi.mocked(getLedgerEntryByMojaloopTransferId).mockImplementation(async () => persistedLedger as never); + + const first = await postWebhook(committedPayload); + const second = await postWebhook(committedPayload); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(tbFetch).toHaveBeenCalledOnce(); + expect(createLedgerEntry).toHaveBeenCalledOnce(); + expect(completePaymentIdempotencyKey).toHaveBeenCalledOnce(); + }); + + it("rejects a concurrent duplicate while the first settlement owns the claim", async () => { + vi.mocked(tbBridgeAvailable).mockResolvedValue(true); + let claimed = false; + vi.mocked(claimPaymentIdempotencyKey).mockImplementation(async () => { + if (claimed) return undefined; + claimed = true; + return { id: 1 } as never; + }); + vi.mocked(tbFetch).mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { id: "tb-transfer-001" }; + }); + + const [first, second] = await Promise.all([ + postWebhook(committedPayload), + postWebhook(committedPayload), + ]); + + expect([first.status, second.status].sort()).toEqual([200, 503]); + expect(tbFetch).toHaveBeenCalledOnce(); + expect(createLedgerEntry).toHaveBeenCalledOnce(); + }); +}); diff --git a/server/webhooks/mojaloop.ts b/server/webhooks/mojaloop.ts index 147148ee..c9316896 100644 --- a/server/webhooks/mojaloop.ts +++ b/server/webhooks/mojaloop.ts @@ -2,9 +2,13 @@ import express, { type Request, type Response } from "express"; import crypto from "crypto"; import { getWebhookSecret } from "../_core/webhookSecretsValidator"; import { + claimPaymentIdempotencyKey, + completePaymentIdempotencyKey, createLedgerEntry, + getLedgerEntryByMojaloopTransferId, getMojaloopTransactionByTransferId, logAuditEvent, + releasePaymentIdempotencyKey, updateMojaloopTransaction, } from "../db"; import { getOrProvisionTraderAccount, SYSTEM_ACCOUNTS } from "../_core/paymentAccountProvisioner"; @@ -56,37 +60,55 @@ export function registerMojaloopWebhookRoute(app: express.Application): void { const tx = await getMojaloopTransactionByTransferId(input.transferId); if (!tx) return res.status(404).json({ error: "Transfer not found" }); - const updateData: Record = { - status: input.transferState, - webhookPayload: input, - }; if (input.transferState === "COMMITTED") { - updateData.fulfilment = input.fulfilment ?? null; - updateData.committedAt = input.completedTimestamp ? new Date(input.completedTimestamp) : new Date(); - await logAuditEvent({ - entityType: "payment", - entityId: tx.id, - action: "mojaloop_webhook_committed", - actorId: tx.initiatedBy, - actorType: "system", - newState: { transferId: input.transferId, status: "COMMITTED" }, - }); - } - if (input.transferState === "ABORTED") { - updateData.abortedAt = new Date(); - updateData.failureReason = input.errorInformation?.errorDescription ?? "Transfer aborted"; - await logAuditEvent({ - entityType: "payment", - entityId: tx.id, - action: "mojaloop_webhook_aborted", - actorId: tx.initiatedBy, - actorType: "system", - newState: { transferId: input.transferId, status: "ABORTED", error: input.errorInformation }, - }); - } - await updateMojaloopTransaction(input.transferId, updateData as never); + const existingLedgerEntry = await getLedgerEntryByMojaloopTransferId(input.transferId); + if (existingLedgerEntry) { + if (tx.status !== "COMMITTED") { + await updateMojaloopTransaction(input.transferId, { + status: "COMMITTED", + fulfilment: input.fulfilment ?? null, + committedAt: input.completedTimestamp ? new Date(input.completedTimestamp) : new Date(), + webhookPayload: input, + }); + await logAuditEvent({ + entityType: "payment", + entityId: tx.id, + action: "mojaloop_webhook_committed", + actorId: tx.initiatedBy, + actorType: "system", + newState: { transferId: input.transferId, status: "COMMITTED" }, + }); + } + return res.json({ success: true, transferId: input.transferId, newStatus: "COMMITTED" }); + } - if (input.transferState === "COMMITTED") { + const keyHash = crypto + .createHash("sha256") + .update(`mojaloop-webhook-settlement:${input.transferId}`) + .digest("hex"); + let claim; + try { + claim = await claimPaymentIdempotencyKey({ + keyHash, + transferId: input.transferId, + responseSnapshot: { transferId: input.transferId, status: "settlement_in_progress" }, + expiresAt: new Date(Date.now() + 86_400_000), + }); + } catch (error) { + console.error("[Mojaloop] Settlement idempotency unavailable:", error); + return res.status(503).json({ + error: "Settlement idempotency unavailable; webhook will be retried", + transferId: input.transferId, + }); + } + if (!claim) { + return res.status(503).json({ + error: "Settlement is already being processed; webhook will be retried", + transferId: input.transferId, + }); + } + + let bridgeAccepted = false; try { if (!(await tbBridgeAvailable())) { throw new Error("TigerBeetle bridge is unavailable"); @@ -105,6 +127,7 @@ export function registerMojaloopWebhookRoute(app: express.Application): void { metadata: { mojaloopTransferId: input.transferId }, }), }); + bridgeAccepted = true; await createLedgerEntry({ tbTransferId: bridgeTransfer.id, debitAccountId, @@ -120,15 +143,59 @@ export function registerMojaloopWebhookRoute(app: express.Application): void { description: `Duty payment settled via Mojaloop webhook (${input.transferId})`, postedAt: new Date(), }); + await updateMojaloopTransaction(input.transferId, { + status: "COMMITTED", + fulfilment: input.fulfilment ?? null, + committedAt: input.completedTimestamp ? new Date(input.completedTimestamp) : new Date(), + webhookPayload: input, + }); + await logAuditEvent({ + entityType: "payment", + entityId: tx.id, + action: "mojaloop_webhook_committed", + actorId: tx.initiatedBy, + actorType: "system", + newState: { transferId: input.transferId, status: "COMMITTED" }, + }); + await completePaymentIdempotencyKey(keyHash, { + transferId: input.transferId, + status: "settled", + tbTransferId: bridgeTransfer.id, + }); } catch (error) { - console.error("[Mojaloop] TigerBeetle settlement unavailable:", error); + console.error("[Mojaloop] Settlement failed:", error); + if (!bridgeAccepted) { + try { + await releasePaymentIdempotencyKey(keyHash); + } catch (releaseError) { + console.error("[Mojaloop] Failed to release settlement claim:", releaseError); + } + } return res.status(503).json({ error: "Ledger settlement unavailable; webhook will be retried", transferId: input.transferId, }); } + return res.json({ success: true, transferId: input.transferId, newStatus: "COMMITTED" }); } + const updateData: Record = { + status: input.transferState, + webhookPayload: input, + }; + if (input.transferState === "ABORTED") { + updateData.abortedAt = new Date(); + updateData.failureReason = input.errorInformation?.errorDescription ?? "Transfer aborted"; + await logAuditEvent({ + entityType: "payment", + entityId: tx.id, + action: "mojaloop_webhook_aborted", + actorId: tx.initiatedBy, + actorType: "system", + newState: { transferId: input.transferId, status: "ABORTED", error: input.errorInformation }, + }); + } + await updateMojaloopTransaction(input.transferId, updateData as never); return res.json({ success: true, transferId: input.transferId, newStatus: input.transferState }); }, ); From d33cd099c3ba280df51e14cea265aa0c9de6774e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:30:47 +0000 Subject: [PATCH 4/5] fix: recover Redis auth and reject invalid Bearer tokens Co-Authored-By: Patrick Munis --- server/_core/redisRateLimiter.ts | 47 ++++++++--- server/_core/sdk.ts | 5 +- server/auth.bearer.test.ts | 67 +++++++++++++++ server/redisRateLimiter.recovery.test.ts | 103 +++++++++++++++++++++++ 4 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 server/auth.bearer.test.ts create mode 100644 server/redisRateLimiter.recovery.test.ts diff --git a/server/_core/redisRateLimiter.ts b/server/_core/redisRateLimiter.ts index 19cbfd44..cb766496 100644 --- a/server/_core/redisRateLimiter.ts +++ b/server/_core/redisRateLimiter.ts @@ -19,32 +19,55 @@ import Redis from "ioredis"; // ─── Redis singleton ────────────────────────────────────────────────────────── let _redis: Redis | null = null; -let _redisFailed = false; function getRedis(): Redis | null { - if (_redisFailed) return null; if (_redis) return _redis; const url = process.env.REDIS_URL ?? "redis://localhost:6379"; try { _redis = new Redis(url, { - lazyConnect: true, + lazyConnect: false, connectTimeout: 3_000, maxRetriesPerRequest: 1, enableOfflineQueue: false, + retryStrategy: (times) => Math.min(times * 200, 2_000), }); _redis.on("error", (err) => { console.warn("[Redis] Connection error (rate limiter falling back to in-memory):", err.message); - _redisFailed = true; - _redis = null; }); return _redis; } catch { - _redisFailed = true; return null; } } +async function getReadyRedis(): Promise { + const redis = getRedis(); + if (!redis) return null; + if (redis.status === "ready") return redis; + + return new Promise((resolve) => { + let settled = false; + const finish = (result: Redis | null) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + redis.removeListener("ready", onReady); + redis.removeListener("error", onError); + redis.removeListener("end", onEnd); + resolve(result); + }; + const onReady = () => finish(redis); + const onError = () => finish(null); + const onEnd = () => finish(null); + const timeout = setTimeout(() => finish(null), 3_000); + + redis.once("ready", onReady); + redis.once("error", onError); + redis.once("end", onEnd); + }); +} + // ─── In-memory fallback ─────────────────────────────────────────────────────── const _memStore = new Map(); @@ -67,7 +90,7 @@ function memIncr(key: string, windowMs: number): number { * Returns the current count (1 = first request in window). */ export async function incrementRateLimit(key: string, windowMs: number): Promise { - const redis = getRedis(); + const redis = await getReadyRedis(); if (!redis) return memIncr(key, windowMs); try { @@ -176,7 +199,7 @@ const SESSION_REVOCATION_TTL_S = 24 * 60 * 60; // 24 hours (matches JWT expiry) * Called on logout to immediately invalidate the JWT. */ export async function revokeSession(sessionId: string): Promise { - const redis = getRedis(); + const redis = await getReadyRedis(); if (!redis) { console.warn("[Redis] Session revocation skipped — Redis unavailable"); return; @@ -194,7 +217,7 @@ export async function revokeSession(sessionId: string): Promise { * Throws if Redis cannot be queried so callers cannot accept an unchecked session. */ export async function isSessionRevoked(sessionId: string): Promise { - const redis = getRedis(); + const redis = await getReadyRedis(); if (!redis) throw new Error("Redis unavailable"); try { const val = await redis.get(`revoked:${sessionId}`); @@ -216,7 +239,7 @@ export async function setIdempotencyKey( key: string, responseSnapshot: unknown ): Promise { - const redis = getRedis(); + const redis = await getReadyRedis(); if (!redis) return true; // fail-open try { @@ -234,7 +257,7 @@ export async function setIdempotencyKey( * Returns null if not found or Redis is unavailable. */ export async function getIdempotencyKey(key: string): Promise { - const redis = getRedis(); + const redis = await getReadyRedis(); if (!redis) return null; try { @@ -248,7 +271,7 @@ export async function getIdempotencyKey(key: string): Promise { // ─── Redis health check ─────────────────────────────────────────────────────── export async function redisHealthCheck(): Promise<{ ok: boolean; latencyMs?: number }> { - const redis = getRedis(); + const redis = await getReadyRedis(); if (!redis) return { ok: false }; try { const start = Date.now(); diff --git a/server/_core/sdk.ts b/server/_core/sdk.ts index 9600039a..f75372dd 100644 --- a/server/_core/sdk.ts +++ b/server/_core/sdk.ts @@ -324,9 +324,10 @@ class SDKServer { if (kcUser) return kcUser; } } catch (err) { - // Keycloak path failed — fall through to Manus session cookie path - console.debug("[Auth] Keycloak Bearer path failed, falling back to session cookie:", String(err)); + console.debug("[Auth] Keycloak Bearer path failed:", String(err)); + throw new Error("Invalid Bearer token", { cause: err }); } + throw new Error("Bearer token did not identify a user"); } // ── Path 2: Manus session cookie (HS256) ────────────────────────────────── diff --git a/server/auth.bearer.test.ts b/server/auth.bearer.test.ts new file mode 100644 index 00000000..85d4bcd5 --- /dev/null +++ b/server/auth.bearer.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./_core/keycloakVerifier", () => ({ + verifyKeycloakToken: vi.fn(), + extractRoleFromPayload: vi.fn(), +})); + +vi.mock("./_core/redisRateLimiter", () => ({ + isSessionRevoked: vi.fn().mockResolvedValue(false), +})); + +vi.mock("./db", () => ({ + getPool: vi.fn().mockReturnValue(null), + getUserByOpenId: vi.fn(), + upsertUser: vi.fn().mockResolvedValue(undefined), +})); + +const cookieUser = { + id: 17, + openId: "cookie-user", + name: "Cookie User", + email: "cookie@example.com", + loginMethod: "manus", + role: "user", + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), +}; + +let sdk: typeof import("./_core/sdk").sdk; + +beforeEach(async () => { + process.env.JWT_SECRET = "auth-test-secret-012345678901234567890123"; + ({ sdk } = await import("./_core/sdk")); + const { verifyKeycloakToken } = await import("./_core/keycloakVerifier"); + const { getUserByOpenId } = await import("./db"); + vi.mocked(verifyKeycloakToken).mockRejectedValue(new Error("invalid token")); + vi.mocked(getUserByOpenId).mockResolvedValue(cookieUser as never); +}); + +describe("Bearer authentication precedence", () => { + it("rejects an invalid Bearer token even when the session cookie is valid", async () => { + const sessionCookie = await sdk.createSessionToken("cookie-user", { name: "Cookie User" }); + + await expect( + sdk.authenticateRequest({ + headers: { + authorization: "Bearer malformed-token", + cookie: `app_session_id=${sessionCookie}`, + }, + } as any), + ).rejects.toThrow("Invalid Bearer token"); + }); + + it("authenticates with a valid session cookie when no Bearer token is present", async () => { + const sessionCookie = await sdk.createSessionToken("cookie-user", { name: "Cookie User" }); + + await expect( + sdk.authenticateRequest({ + headers: { + authorization: undefined, + cookie: `app_session_id=${sessionCookie}`, + }, + } as any), + ).resolves.toMatchObject({ id: cookieUser.id, openId: cookieUser.openId }); + }); +}); diff --git a/server/redisRateLimiter.recovery.test.ts b/server/redisRateLimiter.recovery.test.ts new file mode 100644 index 00000000..232eadaa --- /dev/null +++ b/server/redisRateLimiter.recovery.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type RedisTestState = { available: boolean; instances: any[] }; +function getRedisTestState(): RedisTestState { + const global = globalThis as typeof globalThis & { __redisTestState?: RedisTestState }; + return global.__redisTestState ??= { available: true, instances: [] }; +} + +vi.mock("ioredis", () => { + const redisState = getRedisTestState(); + class MockRedis { + status = "connecting"; + eval = vi.fn().mockResolvedValue(1); + get = vi.fn().mockResolvedValue(null); + set = vi.fn().mockResolvedValue("OK"); + ping = vi.fn().mockResolvedValue("PONG"); + listeners = new Map void>>(); + + on(event: string, listener: (...args: any[]) => void) { + const listeners = this.listeners.get(event) ?? new Set(); + listeners.add(listener); + this.listeners.set(event, listeners); + return this; + } + + once(event: string, listener: (...args: any[]) => void) { + const wrapped = (...args: any[]) => { + this.removeListener(event, wrapped); + listener(...args); + }; + return this.on(event, wrapped); + } + + removeListener(event: string, listener: (...args: any[]) => void) { + this.listeners.get(event)?.delete(listener); + return this; + } + + emit(event: string, ...args: any[]) { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + return true; + } + + constructor() { + (redisState.instances as any[]).push(this); + if (redisState.available) { + queueMicrotask(() => { + this.status = "ready"; + this.emit("ready"); + }); + } + } + + quit = vi.fn(async () => { + this.status = "end"; + this.emit("end"); + }); + } + + return { default: MockRedis }; +}); + +const redisState = getRedisTestState(); + +beforeEach(() => { + redisState.available = true; + redisState.instances.length = 0; +}); + +describe("Redis availability recovery", () => { + it("waits for a healthy client during the first call after initialization", async () => { + vi.resetModules(); + const { closeRedis, incrementRateLimit } = await import("./_core/redisRateLimiter"); + + await expect(incrementRateLimit("cold-start", 60_000)).resolves.toBe(1); + const instances = redisState.instances as any[]; + expect(instances).toHaveLength(1); + expect(instances[0].eval).toHaveBeenCalledOnce(); + + await closeRedis(); + }); + + it("recovers session revocation checks after a transient Redis outage", async () => { + vi.resetModules(); + const { closeRedis, incrementRateLimit, isSessionRevoked } = await import("./_core/redisRateLimiter"); + + await incrementRateLimit("recovery", 60_000); + const redis = (redisState.instances as any[])[0]; + redis.status = "reconnecting"; + const outageCheck = isSessionRevoked("session-001"); + queueMicrotask(() => redis.emit("error", new Error("Redis unavailable"))); + await expect(outageCheck).rejects.toThrow("Redis unavailable"); + + const recoveryCheck = isSessionRevoked("session-001"); + queueMicrotask(() => { + redis.status = "ready"; + redis.emit("ready"); + }); + await expect(recoveryCheck).resolves.toBe(false); + + await closeRedis(); + }); +}); From aa83fa7dbc5b3ef3e26226f400414531e1e9bbbf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:40:18 +0000 Subject: [PATCH 5/5] fix: bound Redis waits and clean Mojaloop failures Co-Authored-By: Patrick Munis --- docs/security/defect-discovery-audit.md | 1 + server/_core/redisRateLimiter.ts | 3 +- server/db.ts | 8 +++ server/mojaloop.initiate.test.ts | 80 ++++++++++++++++++++++++ server/redisRateLimiter.recovery.test.ts | 12 ++++ server/routers/mojaloop.ts | 36 ++++++----- 6 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 server/mojaloop.initiate.test.ts diff --git a/docs/security/defect-discovery-audit.md b/docs/security/defect-discovery-audit.md index 3e00c51b..b0f99157 100644 --- a/docs/security/defect-discovery-audit.md +++ b/docs/security/defect-discovery-audit.md @@ -220,6 +220,7 @@ With `MOJALOOP_WEBHOOK_SECRET` unset, `mojaloop.webhookCallback` accepts `"dev-w | #44 port map divergence | needs the owner to declare which deployment artifact is authoritative | | #42 upload content sniffing | needs a magic-byte/AV scanning dependency decision | | #23 session lifetime | reduced default; refresh-token flow still to be designed | +| Redis outage session availability tradeoff | fail-closed session revocation logs out every user while Redis is unavailable; the client reconnects automatically within seconds after Redis returns (verified at runtime without a process restart), and rate limiting still degrades to memory. Failing open would preserve the original defect, allowing revoked sessions to survive the outage | ## Scores diff --git a/server/_core/redisRateLimiter.ts b/server/_core/redisRateLimiter.ts index cb766496..9fd52ddd 100644 --- a/server/_core/redisRateLimiter.ts +++ b/server/_core/redisRateLimiter.ts @@ -19,6 +19,7 @@ import Redis from "ioredis"; // ─── Redis singleton ────────────────────────────────────────────────────────── let _redis: Redis | null = null; +const REDIS_READINESS_TIMEOUT_MS = 500; function getRedis(): Redis | null { if (_redis) return _redis; @@ -60,7 +61,7 @@ async function getReadyRedis(): Promise { const onReady = () => finish(redis); const onError = () => finish(null); const onEnd = () => finish(null); - const timeout = setTimeout(() => finish(null), 3_000); + const timeout = setTimeout(() => finish(null), REDIS_READINESS_TIMEOUT_MS); redis.once("ready", onReady); redis.once("error", onError); diff --git a/server/db.ts b/server/db.ts index b7af6675..b5e5336f 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1121,6 +1121,14 @@ export async function updateMojaloopTransaction(transferId: string, data: Partia return result[0]; } +export async function deleteMojaloopTransaction(transferId: string) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const { mojaloopTransactions } = await import("../drizzle/schema"); + await db.delete(mojaloopTransactions) + .where(eq(mojaloopTransactions.transferId, transferId)); +} + export async function getMojaloopTransactionsByDeclaration(declarationId: number) { const db = await getDb(); if (!db) return []; diff --git a/server/mojaloop.initiate.test.ts b/server/mojaloop.initiate.test.ts new file mode 100644 index 00000000..f56e6ef8 --- /dev/null +++ b/server/mojaloop.initiate.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMojaloopTransaction, + deleteMojaloopTransaction, + getDb, + getDeclarationById, + getMojaloopTransactionByTransferId, + getMojaloopTransactionsByDeclaration, + getMojaloopTransactionsByUser, + getPaymentsByDeclaration, + logAuditEvent, + updateMojaloopTransaction, +} from "./db"; +import { mojaloopRouter } from "./routers/mojaloop"; + +vi.mock("./db", () => ({ + createMojaloopTransaction: vi.fn(), + deleteMojaloopTransaction: vi.fn(), + getDb: vi.fn(), + getDeclarationById: vi.fn(), + getMojaloopTransactionByTransferId: vi.fn(), + getMojaloopTransactionsByDeclaration: vi.fn(), + getMojaloopTransactionsByUser: vi.fn(), + getPaymentsByDeclaration: vi.fn(), + logAuditEvent: vi.fn(), + updateMojaloopTransaction: vi.fn(), +})); + +const caller = mojaloopRouter.createCaller({ + user: { + id: 9702, + openId: "mojaloop-test-user", + name: "Mojaloop Test User", + email: "mojaloop@test.com", + loginMethod: "manus", + role: "user", + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + }, + req: { method: "POST", headers: {}, cookies: {} } as any, + res: {} as any, +} as any); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getDb).mockResolvedValue(null); + vi.mocked(getDeclarationById).mockResolvedValue({ + id: 7, + traderId: 9702, + totalDue: "500.00", + invoiceCurrency: "GHS", + } as never); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Mojaloop unavailable"))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Mojaloop payment initiation", () => { + it("does not persist a transaction when the switch is unavailable", async () => { + await expect(caller.initiatePayment({ + declarationId: 7, + amount: 500, + currency: "GHS", + fspId: "GCB_BANK", + payerAccount: "payer-12345", + payerName: "Test Payer", + })).rejects.toMatchObject({ + code: "SERVICE_UNAVAILABLE", + message: "Mojaloop switch is unavailable", + }); + + expect(createMojaloopTransaction).not.toHaveBeenCalled(); + expect(deleteMojaloopTransaction).not.toHaveBeenCalled(); + expect(updateMojaloopTransaction).not.toHaveBeenCalled(); + expect(logAuditEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/server/redisRateLimiter.recovery.test.ts b/server/redisRateLimiter.recovery.test.ts index 232eadaa..45232b79 100644 --- a/server/redisRateLimiter.recovery.test.ts +++ b/server/redisRateLimiter.recovery.test.ts @@ -80,6 +80,18 @@ describe("Redis availability recovery", () => { await closeRedis(); }); + it("falls back promptly while Redis remains unavailable", async () => { + redisState.available = false; + vi.resetModules(); + const { closeRedis, incrementRateLimit } = await import("./_core/redisRateLimiter"); + + const startedAt = Date.now(); + await expect(incrementRateLimit("partition", 60_000)).resolves.toBe(1); + expect(Date.now() - startedAt).toBeLessThan(1_000); + + await closeRedis(); + }); + it("recovers session revocation checks after a transient Redis outage", async () => { vi.resetModules(); const { closeRedis, incrementRateLimit, isSessionRevoked } = await import("./_core/redisRateLimiter"); diff --git a/server/routers/mojaloop.ts b/server/routers/mojaloop.ts index a6322f43..91748421 100644 --- a/server/routers/mojaloop.ts +++ b/server/routers/mojaloop.ts @@ -34,6 +34,7 @@ import { createMojaloopTransaction, getMojaloopTransactionByTransferId, updateMojaloopTransaction, + deleteMojaloopTransaction, getMojaloopTransactionsByDeclaration, getMojaloopTransactionsByUser, logAuditEvent, @@ -268,6 +269,12 @@ export const mojaloopRouter = router({ }); } } + + const available = await mojaloopAvailable(); + if (!available) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Mojaloop switch is unavailable" }); + } + // ───────────────────────────────────────────────────────────────────────── const transferId = `TRF-${Date.now()}-${crypto.randomUUID().replace(/-/g, "").slice(0, 10).toUpperCase()}`; const ilpPacket = generateILPPacket(); @@ -303,21 +310,7 @@ export const mojaloopRouter = router({ expiresAt: idemExpiresAt, }).onConflictDoNothing(); } - // Log audit event - await logAuditEvent({ - entityType: "payment", - entityId: txRecord?.id ?? 0, - action: "mojaloop_payment_initiated", - actorId: ctx.user.id, - actorType: "trader", - newState: { transferId, fspId: input.fspId, amount: payableAmount, currency: input.currency }, - }); - // Forward to live Mojaloop switch if available - const available = await mojaloopAvailable(); - if (!available) { - throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Mojaloop switch is unavailable" }); - } try { const response = await fetch(`${MOJALOOP_URL}/transfers`, { method: "POST", @@ -343,10 +336,25 @@ export const mojaloopRouter = router({ throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: `Mojaloop transfer rejected (${response.status}): ${detail}` }); } } catch (e) { + await deleteMojaloopTransaction(transferId).catch(() => {}); + if (idemDb) { + await idemDb.delete(paymentIdempotencyKeys) + .where(eq(paymentIdempotencyKeys.keyHash, keyHash)) + .catch(() => {}); + } if (e instanceof TRPCError) throw e; throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Mojaloop transfer request failed" }); } + await logAuditEvent({ + entityType: "payment", + entityId: txRecord?.id ?? 0, + action: "mojaloop_payment_initiated", + actorId: ctx.user.id, + actorType: "trader", + newState: { transferId, fspId: input.fspId, amount: payableAmount, currency: input.currency }, + }); + return { transferId, status: "PENDING",