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
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,21 @@ ESCROW_INDEXER_ENABLED=false
ESCROW_INDEXER_POLL_INTERVAL_MS=15000
ESCROW_INDEXER_BATCH_SIZE=100
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org

# -------------------------
# KYC Provider Config |
# -------------------------
# External KYC provider integration. Both URL and API key must be set together,
# or both must be absent. Partial config (one without the other) is rejected at boot
# in non-test environments.
#
# KYC_PROVIDER_URL — Base URL of the KYC provider API (must be a valid HTTPS URL).
# KYC_PROVIDER_API_KEY — API key for authenticating with the provider (min 1 char).
# KYC_PROVIDER_SECRET — Optional secondary HMAC/signing secret for the provider.
#
# When unset, the KYC provider is disabled and the /ready check reports "disabled".
# When set, /ready probes the provider URL and reports "healthy" or "unhealthy".
#
# KYC_PROVIDER_URL=https://kyc.example.com
# KYC_PROVIDER_API_KEY=replace-with-your-kyc-api-key
# KYC_PROVIDER_SECRET=replace-with-your-kyc-signing-secret
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@
"<rootDir>/tests/investor.locks.test.js",
"<rootDir>/tests/invoice-correlation.test.js",
"<rootDir>/tests/invoices.test.js",
"<rootDir>/tests/kyc.gating.test.js",
"<rootDir>/tests/marketplace.test.js",
"<rootDir>/tests/maturityReminders.test.js",
"<rootDir>/tests/metrics.test.js",
Expand Down
38 changes: 28 additions & 10 deletions src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,34 @@ const z = require('zod');
* Secrets have no defaults - must be provided.
* @type {z.ZodObject<any>}
*/
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().min(1).max(65535).default(3001),
JWT_SECRET: z.string().min(32), // No default for security
CORS_ALLOWED_ORIGINS: z.string().optional(), // Comma-separated, optional for dev fallbacks
SOROBAN_RPC_URL: z.string().url().default('https://soroban-testnet.stellar.org'),
NETWORK_PASSPHRASE: z.string().default('Test SDF Network ; September 2015'),
SOROBAN_BATCH_CONCURRENCY: z.coerce.number().min(1).max(50).default(5),
SOROBAN_BATCH_TIMEOUT_MS: z.coerce.number().min(100).max(30000).default(5000),
});
const ConfigSchema = z
.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().min(1).max(65535).default(3001),
JWT_SECRET: z.string().min(32), // No default for security
CORS_ALLOWED_ORIGINS: z.string().optional(), // Comma-separated, optional for dev fallbacks
SOROBAN_RPC_URL: z.string().url().default('https://soroban-testnet.stellar.org'),
NETWORK_PASSPHRASE: z.string().default('Test SDF Network ; September 2015'),
SOROBAN_BATCH_CONCURRENCY: z.coerce.number().min(1).max(50).default(5),
SOROBAN_BATCH_TIMEOUT_MS: z.coerce.number().min(100).max(30000).default(5000),
// KYC provider — all optional, but URL+key must be provided together in non-test envs
KYC_PROVIDER_URL: z.string().url().optional(),
KYC_PROVIDER_API_KEY: z.string().min(1).optional(),
KYC_PROVIDER_SECRET: z.string().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.NODE_ENV === 'test') return;
const hasUrl = Boolean(data.KYC_PROVIDER_URL);
const hasKey = Boolean(data.KYC_PROVIDER_API_KEY);
if (hasUrl !== hasKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'KYC_PROVIDER_URL and KYC_PROVIDER_API_KEY must both be set or both be absent.',
path: hasUrl ? ['KYC_PROVIDER_API_KEY'] : ['KYC_PROVIDER_URL'],
});
}
});

/**
* Runtime validated configuration object.
Expand Down
54 changes: 46 additions & 8 deletions src/services/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* @module services/health
*/

const { getKycProviderConfig } = require('./kycService');

/**
* Checks if the Soroban RPC endpoint is reachable.
* @returns {Promise<{status: string, latency?: number, error?: string}>}
Expand Down Expand Up @@ -53,7 +55,7 @@ async function checkDatabaseHealth() {

/**
* Checks escrow reconciliation status.
*
*
* @returns {Promise<{status: string, lastRun?: string, mismatches?: number, error?: string}>} Reconciliation health status.
*/
async function checkReconciliationHealth() {
Expand All @@ -68,12 +70,10 @@ async function checkReconciliationHealth() {
const lastRun = new Date(summary.reconciledAt);
const hoursSinceLastRun = (Date.now() - lastRun.getTime()) / (1000 * 60 * 60);

// Consider unhealthy if last run was more than 25 hours ago (allowing 1 hour grace)
if (hoursSinceLastRun > 25) {
return { status: 'stale', lastRun: summary.reconciledAt, error: 'Reconciliation not run recently' };
}

// Unhealthy if there are mismatches
if (summary.mismatches > 0) {
return { status: 'mismatches', lastRun: summary.reconciledAt, mismatches: summary.mismatches };
}
Expand All @@ -84,21 +84,59 @@ async function checkReconciliationHealth() {
}
}

/**
* Checks if the KYC provider is reachable.
* Only runs when the provider is enabled (URL + API key configured).
* The API key is sent in the Authorization header and never included in the response.
* @returns {Promise<{status: string, latency?: number, error?: string}>}
*/
async function checkKycHealth() {
const kycCfg = getKycProviderConfig();
if (!kycCfg.enabled) {
return { status: 'disabled' };
}

const start = Date.now();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

const response = await fetch(kycCfg.baseUrl, {
method: 'HEAD',
headers: { Authorization: `Bearer ${kycCfg.apiKey}` },
signal: controller.signal,
});

clearTimeout(timeout);
const latency = Date.now() - start;

// Any HTTP response (even 4xx) means the host is reachable
return response.ok || response.status < 500
? { status: 'healthy', latency }
: { status: 'unhealthy', latency, error: `HTTP ${response.status}` };
} catch (error) {
const latency = Date.now() - start;
return { status: 'unhealthy', latency, error: error.message };
}
}

/**
* Performs all dependency health checks.
* @returns {Promise<{healthy: boolean, checks: Object}>}
*/
async function performHealthChecks() {
const [soroban, database, reconciliation] = await Promise.all([
const [soroban, database, kyc] = await Promise.all([
checkSorobanHealth(),
checkDatabaseHealth(),
checkKycHealth(),
]);

const checks = { soroban, database };
// healthy only when soroban is healthy or not configured (unknown)
const healthy = soroban.status === 'healthy' || soroban.status === 'unknown';
const checks = { soroban, database, kyc };
const healthy =
(soroban.status === 'healthy' || soroban.status === 'unknown') &&
(kyc.status === 'healthy' || kyc.status === 'disabled');

return { healthy, checks };
}

module.exports = { checkSorobanHealth, checkDatabaseHealth, performHealthChecks };
module.exports = { checkSorobanHealth, checkDatabaseHealth, checkKycHealth, performHealthChecks };
22 changes: 16 additions & 6 deletions src/services/kycService.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

const logger = require('../logger');
const appConfig = require('../config');

const KYC_STATUSES = {
PENDING: 'pending',
Expand All @@ -21,15 +22,24 @@ const KYC_STATUSES = {
const mockKycRecords = new Map();

/**
* Configuration for external KYC provider
* Loaded from environment variables
* Configuration for external KYC provider.
* Reads from validated config when available, falls back to process.env in test.
*/
const getKycProviderConfig = () => {
let cfg;
try {
cfg = appConfig.get();
} catch {
// config not yet validated (e.g. unit tests that don't call validate())
cfg = process.env;
}
const apiKey = cfg.KYC_PROVIDER_API_KEY || null;
const baseUrl = cfg.KYC_PROVIDER_URL || null;
return {
enabled: !!(process.env.KYC_PROVIDER_API_KEY && process.env.KYC_PROVIDER_URL),
apiKey: process.env.KYC_PROVIDER_API_KEY || null,
baseUrl: process.env.KYC_PROVIDER_URL || null,
apiSecret: process.env.KYC_PROVIDER_SECRET || null, // optional secondary key
enabled: !!(apiKey && baseUrl),
apiKey,
baseUrl,
apiSecret: cfg.KYC_PROVIDER_SECRET || null,
};
};

Expand Down
Loading