Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 48 additions & 0 deletions backend/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
60 changes: 44 additions & 16 deletions backend/src/compliance/amlMonitor.js
Original file line number Diff line number Diff line change
@@ -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)),
},
];

Expand All @@ -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,
Expand Down
93 changes: 70 additions & 23 deletions backend/src/compliance/sanctionsChecker.js
Original file line number Diff line number Diff line change
@@ -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}` };
}
}
}

Expand Down
34 changes: 34 additions & 0 deletions backend/src/middleware/kyc.js
Original file line number Diff line number Diff line change
@@ -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();
}
Loading