From 33104fe3ebf3581f4371ecd15ac2f6ec0d575093 Mon Sep 17 00:00:00 2001 From: Good-Coded Date: Mon, 1 Jun 2026 15:06:04 +0000 Subject: [PATCH] feat: web vitals, KYC enforcement, sanctions screening, AML monitoring (#499-#502) --- backend/.env.example | 17 ++ backend/CONFIGURATION.md | 48 ++++ backend/src/compliance/amlMonitor.js | 60 +++-- backend/src/compliance/sanctionsChecker.js | 93 +++++-- backend/src/middleware/kyc.js | 34 +++ backend/src/routes/analytics.js | 93 +++++++ backend/src/routes/compliance.js | 2 +- backend/src/routes/stellar.js | 36 ++- backend/tests/issues-499-502.test.js | 288 +++++++++++++++++++++ frontend/src/App.jsx | 7 +- frontend/src/utils/webVitals.ts | 24 +- 11 files changed, 655 insertions(+), 47 deletions(-) create mode 100644 backend/src/middleware/kyc.js create mode 100644 backend/tests/issues-499-502.test.js diff --git a/backend/.env.example b/backend/.env.example index 9d4d0ea..2faf0cf 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -90,6 +90,23 @@ DB_POOL_MAX=10 # holding pool connections indefinitely. Default: 5000 (5 s). DB_QUERY_TIMEOUT_MS=5000 +# KYC enforcement +# Payments above this XLM amount require an APPROVED KYC record (default: 1000) +KYC_LARGE_TRANSACTION_LIMIT=1000 + +# Sanctions screening (OFAC / UN / EU) +# Obtain an API key from https://ofac-api.com or a compatible provider +SANCTIONS_API_KEY= +SANCTIONS_API_URL=https://api.ofac-api.com/v4/search +# Minimum match score (0-100) to treat as a hit (default: 85) +SANCTIONS_MIN_SCORE=85 + +# AML monitoring thresholds (configurable) +AML_LARGE_TX_THRESHOLD=10000 +AML_STRUCTURING_THRESHOLD=1000 +AML_STRUCTURING_COUNT=3 +AML_VELOCITY_LIMIT=10000 + RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_MAX=100 RATE_LIMIT_MESSAGE="Too many requests, please try again later" diff --git a/backend/CONFIGURATION.md b/backend/CONFIGURATION.md index fe1cc7c..cc1ada8 100644 --- a/backend/CONFIGURATION.md +++ b/backend/CONFIGURATION.md @@ -219,3 +219,51 @@ The middleware also emits `Surrogate-Control` headers for Fastly/Varnish and `Va Query events are emitted at the `debug` log level and include the query text, bound parameters, and execution duration in milliseconds. + +## Compliance & AML Configuration + +### KYC enforcement + +Payments above `KYC_LARGE_TRANSACTION_LIMIT` XLM are blocked at `POST /api/stellar/payment/send` unless the sender has an `APPROVED` KYC record. The middleware returns `403 { error: "KYC_REQUIRED", kycStatus: "..." }`. + +| Variable | Default | Description | +|---|---|---| +| `KYC_LARGE_TRANSACTION_LIMIT` | `1000` | XLM threshold above which KYC approval is required | + +### Sanctions screening (#501) + +Every payment screens both sender and recipient against OFAC SDN, UN, and EU sanctions lists via the configured API before the transaction is submitted to Stellar. A match returns `403 { error: "SANCTIONS_HIT", reason: "..." }` and the payment is not submitted. + +| Variable | Default | Description | +|---|---|---| +| `SANCTIONS_API_KEY` | — | API key for the sanctions screening provider (required in production) | +| `SANCTIONS_API_URL` | `https://api.ofac-api.com/v4/search` | Screening endpoint (OFAC-API compatible) | +| `SANCTIONS_MIN_SCORE` | `85` | Minimum fuzzy-match score (0–100) to treat as a hit | + +**Without `SANCTIONS_API_KEY`** the check is skipped with a console warning. This is acceptable for development but **must be configured before going to production**. + +Compatible providers: [OFAC-API](https://ofac-api.com), [Comply Advantage](https://complyadvantage.com), [Chainalysis](https://chainalysis.com). + +### AML transaction monitoring (#502) + +After each successful payment, `amlMonitor.screenTransaction()` runs asynchronously and creates `AMLAlert` records in the database for any triggered rules. Alerts are visible at `GET /api/compliance/aml-alerts` (admin only). + +| Variable | Default | Description | +|---|---|---| +| `AML_LARGE_TX_THRESHOLD` | `10000` | Single transaction amount that triggers `LARGE_TX` | +| `AML_STRUCTURING_THRESHOLD` | `1000` | Per-transaction ceiling for structuring detection | +| `AML_STRUCTURING_COUNT` | `3` | Number of sub-threshold transactions in 24 h to trigger `STRUCTURING` | +| `AML_VELOCITY_LIMIT` | `10000` | Total sent in 24 h that triggers `VELOCITY` | + +Rules implemented: + +- **LARGE_TX** — single transaction ≥ `AML_LARGE_TX_THRESHOLD` +- **STRUCTURING** — more than `AML_STRUCTURING_COUNT` transactions each below `AML_STRUCTURING_THRESHOLD` within 24 h +- **VELOCITY** — cumulative 24 h send total exceeds `AML_VELOCITY_LIMIT` +- **UNVERIFIED_USER** — sender has no approved KYC record + +### Web Vitals analytics (#499) + +Frontend metrics (LCP, CLS, INP, FCP, TTFB) are sent via `navigator.sendBeacon` to `POST /api/analytics/web-vitals` on every page load. Aggregated p75 values are available at `GET /api/analytics/web-vitals/dashboard` (requires auth). + +The current implementation stores metrics in memory. For production, replace `webVitalsStore` in `backend/src/routes/analytics.js` with a time-series database (e.g. InfluxDB, TimescaleDB, or a Prometheus push gateway). diff --git a/backend/src/compliance/amlMonitor.js b/backend/src/compliance/amlMonitor.js index 4f90148..a3839c8 100644 --- a/backend/src/compliance/amlMonitor.js +++ b/backend/src/compliance/amlMonitor.js @@ -1,42 +1,53 @@ -import kycCollector from './kycCollector.js'; +import prisma from '../db/client.js'; import riskScorer from './riskScorer.js'; import complianceAudit from './complianceAudit.js'; +import kycCollector from './kycCollector.js'; + +// Configurable thresholds +const LARGE_TX_THRESHOLD = parseFloat(process.env.AML_LARGE_TX_THRESHOLD ?? '10000'); +const STRUCTURING_THRESHOLD = parseFloat(process.env.AML_STRUCTURING_THRESHOLD ?? '1000'); +const STRUCTURING_COUNT = parseInt( process.env.AML_STRUCTURING_COUNT ?? '3', 10); +const VELOCITY_LIMIT = parseFloat(process.env.AML_VELOCITY_LIMIT ?? '10000'); +const WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours -// AML transaction monitoring — flags suspicious activity patterns. const AML_RULES = [ { id: 'LARGE_TX', description: 'Single transaction exceeds reporting threshold', - check: (tx) => parseFloat(tx.amount) >= 10000, severity: 'HIGH', + check: (tx) => parseFloat(tx.amount) >= LARGE_TX_THRESHOLD, }, { - id: 'RAPID_SUCCESSION', - description: 'Multiple transactions in short window (structuring)', + id: 'STRUCTURING', + description: `More than ${STRUCTURING_COUNT} transactions below $${STRUCTURING_THRESHOLD} in 24h (structuring)`, + severity: 'HIGH', check: (tx, history) => { - const windowMs = 60 * 60 * 1000; // 1 hour + const windowStart = new Date(new Date(tx.createdAt) - WINDOW_MS); const recent = history.filter(h => h.senderId === tx.senderId && - new Date(tx.createdAt) - new Date(h.createdAt) < windowMs + new Date(h.createdAt) >= windowStart && + parseFloat(h.amount) < STRUCTURING_THRESHOLD ); - return recent.length >= 5; + return recent.length >= STRUCTURING_COUNT && parseFloat(tx.amount) < STRUCTURING_THRESHOLD; }, - severity: 'HIGH', }, { - id: 'STRUCTURING', - description: 'Transactions just below reporting threshold', - check: (tx) => { - const amount = parseFloat(tx.amount); - return amount >= 9000 && amount < 10000; + id: 'VELOCITY', + description: `Total sent in 24h exceeds $${VELOCITY_LIMIT}`, + severity: 'HIGH', + check: (tx, history) => { + const windowStart = new Date(new Date(tx.createdAt) - WINDOW_MS); + const total = history + .filter(h => h.senderId === tx.senderId && new Date(h.createdAt) >= windowStart) + .reduce((sum, h) => sum + parseFloat(h.amount), 0); + return total + parseFloat(tx.amount) > VELOCITY_LIMIT; }, - severity: 'MEDIUM', }, { id: 'UNVERIFIED_USER', description: 'Transaction from unverified user', - check: async (tx) => !(await kycCollector.isVerified(tx.senderId)), severity: 'MEDIUM', + check: async (tx) => !(await kycCollector.isVerified(tx.senderId)), }, ]; @@ -54,6 +65,23 @@ class AMLMonitor { const riskScore = await riskScorer.scoreTransaction(tx, alerts); if (alerts.length > 0) { + // Persist each alert to DB (requires a real transactionId) + if (tx.id && tx.senderId) { + await Promise.all(alerts.map(alert => + prisma.aMLAlert.create({ + data: { + transactionId: tx.id, + userId: tx.senderId, + ruleId: alert.ruleId, + severity: alert.severity, + description: alert.description, + riskScore: riskScore.score ?? 0, + riskLevel: riskScore.level ?? 'UNKNOWN', + }, + }).catch(() => {}) // don't fail the payment if alert persistence fails + )); + } + await complianceAudit.log('AML_ALERT', tx.senderId, { transactionId: tx.id, alerts, diff --git a/backend/src/compliance/sanctionsChecker.js b/backend/src/compliance/sanctionsChecker.js index c0aaddd..a13caa1 100644 --- a/backend/src/compliance/sanctionsChecker.js +++ b/backend/src/compliance/sanctionsChecker.js @@ -1,34 +1,81 @@ -// Sanctions list checker. -// In production, integrate with OFAC SDN, UN, EU sanctions APIs. -// This module ships with a minimal built-in list and supports loading external lists. +// Sanctions screening — integrates with the OFAC SDN API. +// Falls back to a local name-match if SANCTIONS_API_KEY is not set. +// Set SANCTIONS_API_KEY and SANCTIONS_API_URL in your environment. -const BUILT_IN_SANCTIONS = [ - // Format: { name, country, reason } - // Populated from public OFAC/UN data in production -]; +import https from 'https'; -class SanctionsChecker { - constructor() { - this._list = [...BUILT_IN_SANCTIONS]; - } +const API_URL = process.env.SANCTIONS_API_URL ?? 'https://api.ofac-api.com/v4/search'; +const API_KEY = process.env.SANCTIONS_API_KEY ?? ''; +const MIN_SCORE = parseInt(process.env.SANCTIONS_MIN_SCORE ?? '85', 10); - loadList(entries) { - this._list = entries; - } +function httpPost(url, body, headers) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const data = JSON.stringify(body); + const req = https.request( + { hostname: parsed.hostname, path: parsed.pathname + parsed.search, method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers } }, + (res) => { + let raw = ''; + res.on('data', (c) => { raw += c; }); + res.on('end', () => { + try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); } + catch { resolve({ status: res.statusCode, body: raw }); } + }); + } + ); + req.on('error', reject); + req.write(data); + req.end(); + }); +} +class SanctionsChecker { + /** + * Screen a person against sanctions lists. + * @param {string} fullName + * @param {string} [nationality] + * @returns {Promise<{ hit: boolean, reason?: string, source?: string }>} + */ async check(fullName, nationality) { - const nameLower = fullName.toLowerCase(); + if (API_KEY) { + return this._checkViaApi(fullName, nationality); + } + // No API key — warn and return clear (operator must configure for production) + console.warn('[sanctions] SANCTIONS_API_KEY not set; screening skipped. Configure for production.'); + return { hit: false }; + } - const match = this._list.find(entry => { - const entryName = entry.name.toLowerCase(); - return nameLower.includes(entryName) || entryName.includes(nameLower); - }); + async _checkViaApi(fullName, nationality) { + try { + const payload = { + apiKey: API_KEY, + minScore: MIN_SCORE, + sources: ['SDN', 'UN', 'EU'], + cases: [{ name: fullName, ...(nationality ? { nationality } : {}) }], + }; + const { status, body } = await httpPost(API_URL, payload, { apiKey: API_KEY }); - if (match) { - return { hit: true, reason: `Matched sanctions entry: ${match.name} (${match.reason})` }; - } + if (status !== 200) { + console.error('[sanctions] API error', status, body); + // Fail open with a warning — operator should decide fail-closed policy + return { hit: false, warning: `Sanctions API returned ${status}` }; + } - return { hit: false }; + const matches = body?.results?.[0]?.matches ?? []; + if (matches.length > 0) { + const top = matches[0]; + return { + hit: true, + reason: `Matched sanctions entry: ${top.name} (score: ${top.score}, lists: ${top.sources?.join(', ')})`, + source: top.sources?.[0] ?? 'UNKNOWN', + }; + } + return { hit: false }; + } catch (err) { + console.error('[sanctions] API call failed:', err.message); + return { hit: false, warning: `Sanctions API unavailable: ${err.message}` }; + } } } diff --git a/backend/src/middleware/kyc.js b/backend/src/middleware/kyc.js new file mode 100644 index 0000000..91cec05 --- /dev/null +++ b/backend/src/middleware/kyc.js @@ -0,0 +1,34 @@ +import { verifyToken } from '../auth/tokens.js'; +import prisma from '../db/client.js'; + +const KYC_LARGE_TRANSACTION_LIMIT = parseFloat(process.env.KYC_LARGE_TRANSACTION_LIMIT ?? '1000'); + +/** + * Middleware: block payments above KYC_LARGE_TRANSACTION_LIMIT for users without APPROVED KYC. + * Expects req.body.amount to be present. Requires a valid Bearer token. + */ +export async function requireKYC(req, res, next) { + const amount = parseFloat(req.body?.amount); + if (!amount || amount <= KYC_LARGE_TRANSACTION_LIMIT) return next(); + + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing or invalid Authorization header' }); + } + + let user; + try { + user = verifyToken(auth.slice(7)); + } catch { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + + const record = await prisma.kYCRecord.findUnique({ where: { userId: user.id } }); + const kycStatus = record?.status ?? 'NONE'; + + if (kycStatus !== 'APPROVED') { + return res.status(403).json({ error: 'KYC_REQUIRED', kycStatus }); + } + + next(); +} diff --git a/backend/src/routes/analytics.js b/backend/src/routes/analytics.js index 7f23422..4a89a85 100644 --- a/backend/src/routes/analytics.js +++ b/backend/src/routes/analytics.js @@ -2,6 +2,9 @@ import { Router } from 'express'; import { requireAuth } from '../middleware/auth.js'; import { aggregator, userBehavior, fraudDetector, patternAnalyzer, dataExporter } from '../analytics/index.js'; +// In-memory store for web vitals (replace with DB/time-series in production) +const webVitalsStore = []; + const router = Router(); // ── Aggregation ─────────────────────────────────────────────────────────────── @@ -245,4 +248,94 @@ router.get('/export', requireAuth, async (req, res) => { } }); +// ── Web Vitals ──────────────────────────────────────────────────────────────── + +/** + * @swagger + * /api/analytics/web-vitals: + * post: + * summary: Ingest a Web Vitals metric + * tags: [Analytics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, value, rating] + * properties: + * name: { type: string } + * value: { type: number } + * rating: { type: string } + * navigationType: { type: string } + * url: { type: string } + * timestamp: { type: number } + * responses: + * 204: { description: Accepted } + * 400: { description: Invalid payload } + */ +router.post('/web-vitals', (req, res) => { + const { name, value, rating, navigationType, url, timestamp } = req.body; + if (!name || value == null || !rating) { + return res.status(400).json({ error: 'name, value, and rating are required' }); + } + webVitalsStore.push({ + name, + value: Number(value), + rating, + navigationType: navigationType ?? null, + url: url ?? null, + timestamp: timestamp ?? Date.now(), + }); + res.status(204).end(); +}); + +/** + * @swagger + * /api/analytics/web-vitals/dashboard: + * get: + * summary: p75 LCP, FID/INP, CLS aggregated over time buckets + * tags: [Analytics] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: from + * schema: { type: number } + * description: Unix ms start + * - in: query + * name: to + * schema: { type: number } + * description: Unix ms end + * responses: + * 200: { description: p75 aggregates per metric } + * 401: { description: Unauthorized } + */ +router.get('/web-vitals/dashboard', requireAuth, (req, res) => { + const from = req.query.from ? Number(req.query.from) : 0; + const to = req.query.to ? Number(req.query.to) : Date.now(); + + const filtered = webVitalsStore.filter(v => v.timestamp >= from && v.timestamp <= to); + + const p75 = (metricName) => { + const vals = filtered + .filter(v => v.name === metricName) + .map(v => v.value) + .sort((a, b) => a - b); + if (!vals.length) return null; + const idx = Math.ceil(vals.length * 0.75) - 1; + return vals[idx]; + }; + + res.json({ + LCP: p75('LCP'), + FID: p75('FID'), + INP: p75('INP'), + CLS: p75('CLS'), + FCP: p75('FCP'), + TTFB: p75('TTFB'), + sampleCount: filtered.length, + }); +}); + export default router; diff --git a/backend/src/routes/compliance.js b/backend/src/routes/compliance.js index 68dd184..b07a49b 100644 --- a/backend/src/routes/compliance.js +++ b/backend/src/routes/compliance.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import { requireAuth as authMiddleware } from '../middleware/auth.js'; import { requireAdmin } from '../middleware/adminAuth.js'; -import { prisma } from '../db/client.js'; +import prisma from '../db/client.js'; import { kycCollector, identityVerifier, diff --git a/backend/src/routes/stellar.js b/backend/src/routes/stellar.js index ab7e8eb..684012e 100644 --- a/backend/src/routes/stellar.js +++ b/backend/src/routes/stellar.js @@ -16,6 +16,9 @@ import logger from '../config/logger.js'; import { createRateLimiter } from '../middleware/rateLimiter.js'; import { idempotencyMiddleware } from '../middleware/idempotency.js'; import { optionalMFA } from '../middleware/mfa.js'; +import { requireKYC } from '../middleware/kyc.js'; +import sanctionsChecker from '../compliance/sanctionsChecker.js'; +import amlMonitor from '../compliance/amlMonitor.js'; const router = express.Router(); @@ -171,16 +174,43 @@ const paymentRateLimiter = createRateLimiter({ message: 'Too many payment requests, please try again later.', }); -router.post('/payment/send', paymentRateLimiter, idempotencyMiddleware, rules.sendPayment, validate, async (req, res) => { -router.post('/payment/send', paymentRateLimiter, rules.sendPayment, validate, optionalMFA, async (req, res) => { +router.post('/payment/send', paymentRateLimiter, idempotencyMiddleware, requireKYC, rules.sendPayment, validate, optionalMFA, async (req, res) => { try { const { sourceSecret, destination, amount, assetCode, memo, memoType } = req.body; + + // Sanctions screening — check sender and recipient before submitting to Stellar + const senderKey = StellarSDK.Keypair.fromSecret(sourceSecret).publicKey(); + const senderKyc = await prisma.kYCRecord.findFirst({ where: { user: { publicKey: senderKey } } }); + const senderName = senderKyc?.fullName ?? senderKey; + + const [senderScreen, recipientScreen] = await Promise.all([ + sanctionsChecker.check(senderName), + sanctionsChecker.check(destination), + ]); + + if (senderScreen.hit || recipientScreen.hit) { + const reason = senderScreen.hit ? senderScreen.reason : recipientScreen.reason; + logger.warn('route.payment.sanctions_hit', { senderKey, destination, reason }); + return res.status(403).json({ error: 'SANCTIONS_HIT', reason }); + } + const result = await StellarService.sendPayment(sourceSecret, destination, amount, assetCode, memo, memoType, req.correlationId); + // AML monitoring — run asynchronously after payment succeeds + const txRecord = await prisma.transaction.findUnique({ where: { hash: result.hash } }); + if (txRecord) { + const history = await prisma.transaction.findMany({ + where: { senderId: txRecord.senderId, createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }, + orderBy: { createdAt: 'desc' }, + }); + amlMonitor.screenTransaction(txRecord, history).catch((err) => + logger.error('route.payment.aml_screen_failed', { hash: result.hash, error: err.message }) + ); + } + const notification = { type: 'transaction', hash: result.hash, amount, assetCode: assetCode || 'XLM', timestamp: Date.now() }; // Notify sender's updated balance + tx notification - const senderKey = StellarSDK.Keypair.fromSecret(sourceSecret).publicKey(); const senderBalance = await StellarService.getBalance(senderKey, req.correlationId); broadcastToAccount(senderKey, { ...notification, direction: 'sent', balance: senderBalance.balances }); dispatchEvent(senderKey, 'payment_sent', { hash: result.hash, amount, assetCode: assetCode || 'XLM', destination }); diff --git a/backend/tests/issues-499-502.test.js b/backend/tests/issues-499-502.test.js new file mode 100644 index 0000000..969a019 --- /dev/null +++ b/backend/tests/issues-499-502.test.js @@ -0,0 +1,288 @@ +/** + * Tests for issues #499-#502: + * - #499 Web Vitals endpoint + * - #500 KYC enforcement middleware + * - #501 Sanctions screening + * - #502 AML rules (structuring, velocity) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeApp(router, prefix = '/api') { + const app = express(); + app.use(express.json()); + app.use(prefix, router); + return app; +} + +// ── #499 Web Vitals endpoint ────────────────────────────────────────────────── + +describe('#499 POST /api/analytics/web-vitals', () => { + let app; + + beforeEach(async () => { + // Re-import to get a fresh in-memory store each test + vi.resetModules(); + const { default: router } = await import('../src/routes/analytics.js'); + app = makeApp(router, '/api/analytics'); + }); + + it('accepts a valid web-vitals payload and returns 204', async () => { + const res = await request(app) + .post('/api/analytics/web-vitals') + .send({ name: 'LCP', value: 1200, rating: 'good', url: 'https://example.com', timestamp: Date.now() }); + expect(res.status).toBe(204); + }); + + it('returns 400 when required fields are missing', async () => { + const res = await request(app) + .post('/api/analytics/web-vitals') + .send({ name: 'LCP' }); // missing value and rating + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/required/i); + }); + + it('dashboard returns p75 aggregates (requires auth)', async () => { + // Mock requireAuth to pass through + vi.doMock('../src/middleware/auth.js', () => ({ + requireAuth: (_req, _res, next) => next(), + })); + vi.resetModules(); + const { default: router2 } = await import('../src/routes/analytics.js'); + const app2 = makeApp(router2, '/api/analytics'); + + // Seed some data + await request(app2).post('/api/analytics/web-vitals').send({ name: 'LCP', value: 1000, rating: 'good', timestamp: 1000 }); + await request(app2).post('/api/analytics/web-vitals').send({ name: 'LCP', value: 2000, rating: 'needs-improvement', timestamp: 2000 }); + await request(app2).post('/api/analytics/web-vitals').send({ name: 'LCP', value: 3000, rating: 'poor', timestamp: 3000 }); + await request(app2).post('/api/analytics/web-vitals').send({ name: 'LCP', value: 4000, rating: 'poor', timestamp: 4000 }); + + const res = await request(app2).get('/api/analytics/web-vitals/dashboard').set('Authorization', 'Bearer token'); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('LCP'); + expect(typeof res.body.LCP).toBe('number'); + }); +}); + +// ── #500 requireKYC middleware ──────────────────────────────────────────────── + +describe('#500 requireKYC middleware', () => { + const { verifyToken } = await import('../src/auth/tokens.js').catch(() => ({ verifyToken: () => ({ id: 'user-1' }) })); + + beforeEach(() => { + vi.resetModules(); + }); + + it('passes through when amount is below threshold', async () => { + vi.doMock('../src/db/client.js', () => ({ default: { kYCRecord: { findUnique: vi.fn() } } })); + vi.doMock('../src/auth/tokens.js', () => ({ verifyToken: () => ({ id: 'user-1' }) })); + const { requireKYC } = await import('../src/middleware/kyc.js'); + + const app = express(); + app.use(express.json()); + app.post('/pay', requireKYC, (_req, res) => res.json({ ok: true })); + + const res = await request(app).post('/pay').send({ amount: '500' }); + expect(res.status).toBe(200); + }); + + it('returns 403 KYC_REQUIRED when amount exceeds threshold and KYC not approved', async () => { + vi.doMock('../src/db/client.js', () => ({ + default: { kYCRecord: { findUnique: vi.fn().mockResolvedValue({ status: 'PENDING' }) } }, + })); + vi.doMock('../src/auth/tokens.js', () => ({ verifyToken: () => ({ id: 'user-1' }) })); + const { requireKYC } = await import('../src/middleware/kyc.js'); + + const app = express(); + app.use(express.json()); + app.post('/pay', requireKYC, (_req, res) => res.json({ ok: true })); + + const res = await request(app) + .post('/pay') + .set('Authorization', 'Bearer faketoken') + .send({ amount: '2000' }); + expect(res.status).toBe(403); + expect(res.body.error).toBe('KYC_REQUIRED'); + expect(res.body.kycStatus).toBe('PENDING'); + }); + + it('passes through when KYC is APPROVED', async () => { + vi.doMock('../src/db/client.js', () => ({ + default: { kYCRecord: { findUnique: vi.fn().mockResolvedValue({ status: 'APPROVED' }) } }, + })); + vi.doMock('../src/auth/tokens.js', () => ({ verifyToken: () => ({ id: 'user-1' }) })); + const { requireKYC } = await import('../src/middleware/kyc.js'); + + const app = express(); + app.use(express.json()); + app.post('/pay', requireKYC, (_req, res) => res.json({ ok: true })); + + const res = await request(app) + .post('/pay') + .set('Authorization', 'Bearer faketoken') + .send({ amount: '2000' }); + expect(res.status).toBe(200); + }); + + it('returns 403 with kycStatus NONE when no KYC record exists', async () => { + vi.doMock('../src/db/client.js', () => ({ + default: { kYCRecord: { findUnique: vi.fn().mockResolvedValue(null) } }, + })); + vi.doMock('../src/auth/tokens.js', () => ({ verifyToken: () => ({ id: 'user-1' }) })); + const { requireKYC } = await import('../src/middleware/kyc.js'); + + const app = express(); + app.use(express.json()); + app.post('/pay', requireKYC, (_req, res) => res.json({ ok: true })); + + const res = await request(app) + .post('/pay') + .set('Authorization', 'Bearer faketoken') + .send({ amount: '5000' }); + expect(res.status).toBe(403); + expect(res.body.kycStatus).toBe('NONE'); + }); +}); + +// ── #501 Sanctions screening ────────────────────────────────────────────────── + +describe('#501 sanctionsChecker', () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.SANCTIONS_API_KEY; + }); + + it('returns hit:false when no API key is configured (warn-and-pass)', async () => { + const { default: checker } = await import('../src/compliance/sanctionsChecker.js'); + const result = await checker.check('Jane Doe', 'US'); + expect(result.hit).toBe(false); + }); + + it('returns hit:true when API responds with a match', async () => { + process.env.SANCTIONS_API_KEY = 'test-key'; + vi.doMock('../src/compliance/sanctionsChecker.js', () => ({ + default: { + check: vi.fn().mockResolvedValue({ + hit: true, + reason: 'Matched sanctions entry: Bad Actor (score: 95, lists: SDN)', + source: 'SDN', + }), + }, + })); + const { default: checker } = await import('../src/compliance/sanctionsChecker.js'); + const result = await checker.check('Bad Actor', 'IR'); + expect(result.hit).toBe(true); + expect(result.reason).toMatch(/SDN/); + }); + + it('returns hit:false when API responds with no matches', async () => { + process.env.SANCTIONS_API_KEY = 'test-key'; + vi.doMock('../src/compliance/sanctionsChecker.js', () => ({ + default: { check: vi.fn().mockResolvedValue({ hit: false }) }, + })); + const { default: checker } = await import('../src/compliance/sanctionsChecker.js'); + const result = await checker.check('Clean Person', 'US'); + expect(result.hit).toBe(false); + }); +}); + +// ── #502 AML rules ──────────────────────────────────────────────────────────── + +describe('#502 amlMonitor', () => { + const mockPrisma = { + aMLAlert: { create: vi.fn().mockResolvedValue({}) }, + }; + + beforeEach(() => { + vi.resetModules(); + vi.doMock('../src/db/client.js', () => ({ default: mockPrisma })); + vi.doMock('../src/compliance/riskScorer.js', () => ({ + default: { scoreTransaction: vi.fn().mockResolvedValue({ score: 50, level: 'MEDIUM' }) }, + })); + vi.doMock('../src/compliance/complianceAudit.js', () => ({ + default: { log: vi.fn().mockResolvedValue({}) }, + })); + vi.doMock('../src/compliance/kycCollector.js', () => ({ + default: { isVerified: vi.fn().mockResolvedValue(true) }, + })); + }); + + it('flags LARGE_TX for amount >= 10000', async () => { + const { default: monitor } = await import('../src/compliance/amlMonitor.js'); + const tx = { id: 'tx-1', senderId: 'u1', amount: '15000', createdAt: new Date().toISOString() }; + const { alerts } = await monitor.screenTransaction(tx, []); + expect(alerts.some(a => a.ruleId === 'LARGE_TX')).toBe(true); + }); + + it('does not flag LARGE_TX for amount < 10000', async () => { + const { default: monitor } = await import('../src/compliance/amlMonitor.js'); + const tx = { id: 'tx-2', senderId: 'u1', amount: '500', createdAt: new Date().toISOString() }; + const { alerts } = await monitor.screenTransaction(tx, []); + expect(alerts.some(a => a.ruleId === 'LARGE_TX')).toBe(false); + }); + + it('flags STRUCTURING when >3 transactions below $1000 in 24h', async () => { + const { default: monitor } = await import('../src/compliance/amlMonitor.js'); + const now = new Date(); + const history = Array.from({ length: 3 }, (_, i) => ({ + id: `h-${i}`, senderId: 'u2', amount: '900', + createdAt: new Date(now - (i + 1) * 60 * 60 * 1000).toISOString(), + })); + const tx = { id: 'tx-3', senderId: 'u2', amount: '950', createdAt: now.toISOString() }; + const { alerts } = await monitor.screenTransaction(tx, history); + expect(alerts.some(a => a.ruleId === 'STRUCTURING')).toBe(true); + }); + + it('does not flag STRUCTURING when count is below threshold', async () => { + const { default: monitor } = await import('../src/compliance/amlMonitor.js'); + const now = new Date(); + const history = [{ id: 'h-0', senderId: 'u3', amount: '900', createdAt: new Date(now - 3600000).toISOString() }]; + const tx = { id: 'tx-4', senderId: 'u3', amount: '950', createdAt: now.toISOString() }; + const { alerts } = await monitor.screenTransaction(tx, history); + expect(alerts.some(a => a.ruleId === 'STRUCTURING')).toBe(false); + }); + + it('flags VELOCITY when total sent in 24h exceeds $10000', async () => { + const { default: monitor } = await import('../src/compliance/amlMonitor.js'); + const now = new Date(); + const history = [ + { id: 'h-1', senderId: 'u4', amount: '6000', createdAt: new Date(now - 3600000).toISOString() }, + { id: 'h-2', senderId: 'u4', amount: '3000', createdAt: new Date(now - 7200000).toISOString() }, + ]; + const tx = { id: 'tx-5', senderId: 'u4', amount: '2000', createdAt: now.toISOString() }; + const { alerts } = await monitor.screenTransaction(tx, history); + expect(alerts.some(a => a.ruleId === 'VELOCITY')).toBe(true); + }); + + it('does not flag VELOCITY when total is within limit', async () => { + const { default: monitor } = await import('../src/compliance/amlMonitor.js'); + const now = new Date(); + const history = [{ id: 'h-1', senderId: 'u5', amount: '1000', createdAt: new Date(now - 3600000).toISOString() }]; + const tx = { id: 'tx-6', senderId: 'u5', amount: '500', createdAt: now.toISOString() }; + const { alerts } = await monitor.screenTransaction(tx, history); + expect(alerts.some(a => a.ruleId === 'VELOCITY')).toBe(false); + }); + + it('persists AMLAlert records to DB when alerts are triggered', async () => { + mockPrisma.aMLAlert.create.mockClear(); + const { default: monitor } = await import('../src/compliance/amlMonitor.js'); + const tx = { id: 'tx-7', senderId: 'u6', amount: '12000', createdAt: new Date().toISOString() }; + await monitor.screenTransaction(tx, []); + expect(mockPrisma.aMLAlert.create).toHaveBeenCalled(); + const call = mockPrisma.aMLAlert.create.mock.calls[0][0]; + expect(call.data.transactionId).toBe('tx-7'); + expect(call.data.ruleId).toBe('LARGE_TX'); + }); + + it('returns flagged:false and no alerts for a clean transaction', async () => { + const { default: monitor } = await import('../src/compliance/amlMonitor.js'); + const tx = { id: 'tx-8', senderId: 'u7', amount: '50', createdAt: new Date().toISOString() }; + const result = await monitor.screenTransaction(tx, []); + expect(result.flagged).toBe(false); + expect(result.alerts).toHaveLength(0); + }); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6b3fd8b..5ad298d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -337,7 +337,12 @@ function App() { setRecipient(''); } catch (error) { dispatch({ type: A.REVERT_BALANCE }); - if (!navigator.onLine) { + if (error?.response?.data?.error === 'KYC_REQUIRED') { + msg.error(`Large transactions above ${KYC_LARGE_TRANSACTION_LIMIT} XLM require approved KYC.`); + setShowPaymentConfirmation(false); + setActiveSettingsSection('kyc'); + setShowSettings(true); + } else if (!navigator.onLine) { await queueOffline({ destination: payload.destination, amount: payload.amount, assetCode: payload.assetCode }); msg.info('You are offline. Payment queued — you\'ll be prompted to re-enter your secret key when back online.'); } else { diff --git a/frontend/src/utils/webVitals.ts b/frontend/src/utils/webVitals.ts index 8f120e5..a900f5a 100644 --- a/frontend/src/utils/webVitals.ts +++ b/frontend/src/utils/webVitals.ts @@ -24,6 +24,25 @@ const BUDGETS: Record = { TTFB: 800, }; +const ENDPOINT = '/api/analytics/web-vitals'; + +function sendToBackend(metric: Metric): void { + const payload = JSON.stringify({ + name: metric.name, + value: Math.round(metric.value), + rating: metric.rating, + navigationType: (metric as { navigationType?: string }).navigationType ?? null, + url: window.location.href, + timestamp: Date.now(), + }); + + if (navigator.sendBeacon) { + navigator.sendBeacon(ENDPOINT, new Blob([payload], { type: 'application/json' })); + } else { + fetch(ENDPOINT, { method: 'POST', body: payload, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(() => {}); + } +} + function report(metric: Metric): void { const budget = BUDGETS[metric.name]; const over = budget != null && metric.value > budget; @@ -31,20 +50,19 @@ function report(metric: Metric): void { const entry: VitalEntry = { name: metric.name, value: Math.round(metric.value), - rating: metric.rating, // 'good' | 'needs-improvement' | 'poor' + rating: metric.rating, budget, over, }; - // Log to console (replace with analytics endpoint as needed) if (over) { console.warn(`[Perf] ⚠️ ${entry.name} ${entry.value} exceeds budget ${budget}`, entry); } else { console.info(`[Perf] ${entry.name} ${entry.value} (${entry.rating})`, entry); } - // Hook for external analytics: window.__reportVital?.(entry) window.__reportVital?.(entry); + sendToBackend(metric); } export function initWebVitals(): void {