feat: four super-admin keys and production customer API security
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m18s
CI/CD Pipeline / Security Scanning (push) Successful in 2m34s
CI/CD Pipeline / Lint and Format (push) Failing after 54s
CI/CD Pipeline / Terraform Validation (push) Failing after 27s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 28s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 39s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 31s
Validation / validate-genesis (push) Successful in 28s
Validation / validate-terraform (push) Failing after 31s
Validation / validate-kubernetes (push) Failing after 11s
Validation / validate-smart-contracts (push) Failing after 11s
Validation / validate-security (push) Failing after 1m27s
Validation / validate-documentation (push) Failing after 21s
Verify Deployment / Verify Deployment (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 05:09:08 -07:00
parent 84f12c7edc
commit 064239be13
39 changed files with 1094 additions and 99 deletions

View File

@@ -20,7 +20,20 @@ OMNL_ALLOW_CHAIN_MINT_EXECUTE=1
# === Auth ===
OMNL_API_KEY=
VITE_OMNL_API_KEY=
# Do NOT set VITE_OMNL_API_KEY in production (nginx injects server-side)
# === Four super-admin portal keys (one per LXC 58255828) ===
OMNL_SUPER_ADMIN_OFFICE24_KEY=
OMNL_SUPER_ADMIN_EXCHANGE_KEY=
OMNL_SUPER_ADMIN_SECURE_KEY=
OMNL_SUPER_ADMIN_FOREX_KEY=
OMNL_PORTAL_INTERNAL_SECRET=
# === Customer API keys (read + trade scopes only; comma-separated key:scope) ===
OMNL_CUSTOMER_API_KEYS=
OMNL_CUSTOMER_API_KEY_1=
OMNL_CUSTOMER_SECURITY_PRODUCTION=1
JWT_SECRET=
# === On-chain M2 mint / transfer (Chain 138 operator) ===
OMNL_MINT_OPERATOR_PRIVATE_KEY=

View File

@@ -0,0 +1,30 @@
{
"version": "1.0.0",
"production": true,
"neverExposeOperatorKeyInBrowser": true,
"portalInjectAuthViaNginx": true,
"requireApiKeyAllExchangeRoutes": true,
"customerAllowedScopes": ["read", "trade"],
"superAdminOnlyScopes": ["settle", "token_load", "transfer", "admin"],
"rateLimits": {
"customerPerMinute": 30,
"superAdminPerMinute": 120,
"anonymousPerMinute": 0
},
"corsOrigins": [
"https://secure.d-bis.org",
"https://online.omdnl.org",
"https://office24.omdnl.org",
"https://digital.omdnl.org",
"https://exchange.d-bis.org",
"https://exchange.omdnl.org",
"https://forex.omdnl.org"
],
"publicEndpoints": [
"/health",
"/health/ledger",
"/money-supply/public",
"/public/money-supply"
],
"notes": "Customer keys: OMNL_CUSTOMER_API_KEYS=key:read|trade or OMNL_CUSTOMER_API_KEY_1..3. Super-admin keys: OMNL_SUPER_ADMIN_*_KEY in config/omnl-super-admins.v1.json. Portal browsers use nginx-injected auth — never VITE_OMNL_API_KEY."
}

View File

@@ -0,0 +1,40 @@
{
"version": "1.0.0",
"description": "Fineract / OMNL separation-of-duties matrix — four super admins, three Fineract service accounts",
"tenant": "omnl",
"officeId": 24,
"superAdmins": [
{ "id": "sa-office24", "fineractUser": "ali_hospitallers_admin", "portalCtid": 5825 },
{ "id": "sa-exchange", "fineractUser": "ali_hospitallers_operator", "portalCtid": 5826 },
{ "id": "sa-secure", "fineractUser": "ali_hospitallers_admin", "portalCtid": 5827 },
{ "id": "sa-forex", "fineractUser": "ali_hospitallers_operator", "portalCtid": 5828 }
],
"fineractUsers": [
{
"username": "ali_hospitallers_tenant",
"role": "tenant_api",
"permissions": ["journal_read", "journal_post", "money_supply_read"]
},
{
"username": "ali_hospitallers_admin",
"role": "office_admin",
"permissions": ["journal_post", "office_config", "settlement_approve", "super_admin_portal"]
},
{
"username": "ali_hospitallers_operator",
"role": "operator",
"permissions": ["journal_post", "transfer_initiate", "swap_settle", "super_admin_portal"]
}
],
"separationOfDuties": {
"journalPostRequires": ["tenant_api", "office_admin", "operator"],
"materialOpeningRequires": ["office_admin", "head_office_dual_control"],
"tokenLoadRequires": ["super_admin_portal"],
"externalTransferRequires": ["super_admin_portal", "compliance_screening"]
},
"customerApi": {
"role": "customer",
"permissions": ["quote_read", "token_list", "swap_plan", "swap_execute"],
"denied": ["token_load", "transfer_internal", "transfer_external", "swap_settle", "money_supply_admin"]
}
}

View File

@@ -0,0 +1,47 @@
{
"version": "1.0.0",
"description": "Four OMNL super-admin operators — one per banking portal LXC (58255828). API keys via env vars (never commit values).",
"superAdmins": [
{
"id": "sa-office24",
"username": "zardasht.office24",
"displayName": "Zardasht Office 24 Super Admin",
"portalCtid": 5825,
"portalName": "omnl-office24-portal",
"envKey": "OMNL_SUPER_ADMIN_OFFICE24_KEY",
"fineractUser": "ali_hospitallers_admin",
"products": ["office-24", "digital-swap"]
},
{
"id": "sa-exchange",
"username": "zardasht.exchange",
"displayName": "DBIS Exchange Super Admin",
"portalCtid": 5826,
"portalName": "dbis-exchange-portal",
"envKey": "OMNL_SUPER_ADMIN_EXCHANGE_KEY",
"fineractUser": "ali_hospitallers_operator",
"products": ["dbis-trade"]
},
{
"id": "sa-secure",
"username": "zardasht.secure",
"displayName": "Central Bank / Online Banking Super Admin",
"portalCtid": 5827,
"portalName": "dbis-secure-portal",
"envKey": "OMNL_SUPER_ADMIN_SECURE_KEY",
"fineractUser": "ali_hospitallers_admin",
"products": ["central-bank", "online-banking"]
},
{
"id": "sa-forex",
"username": "zardasht.forex",
"displayName": "Forex Trade Super Admin",
"portalCtid": 5828,
"portalName": "dbis-forex-portal",
"envKey": "OMNL_SUPER_ADMIN_FOREX_KEY",
"fineractUser": "ali_hospitallers_operator",
"products": ["forex-trade"]
}
],
"notes": "Run scripts/deployment/seed-omnl-super-admin-keys.sh to generate keys. Each LXC receives its portal key as OMNL_API_KEY via push-portal-env-to-lxc.sh."
}

View File

@@ -1,6 +1,7 @@
# Shared OMNL Bank proxy locations (include inside each server block)
location /api/v1/ {
include /etc/nginx/omnl-portal-auth-current.conf.inc;
proxy_pass http://omnl_api/api/v1/;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -11,6 +12,7 @@ location /api/v1/ {
}
location /settlement/ {
include /etc/nginx/omnl-portal-auth-current.conf.inc;
proxy_pass http://omnl_settlement/api/v1/settlement/;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -22,6 +24,7 @@ location /settlement/ {
}
location /exchange/ {
include /etc/nginx/omnl-portal-auth-current.conf.inc;
proxy_pass http://omnl_exchange/api/v1/exchange/;
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@@ -33,6 +33,7 @@ http {
index index.html;
location /api/v1/ {
include /srv/zardasht-portal/deploy/nginx/omnl-portal-auth-current.conf.inc;
proxy_pass http://omnl_lxc_api/api/v1/;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -40,6 +41,7 @@ http {
}
location /settlement/ {
include /srv/zardasht-portal/deploy/nginx/omnl-portal-auth-current.conf.inc;
proxy_pass http://omnl_lxc_settlement/api/v1/settlement/;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -47,6 +49,7 @@ http {
}
location /exchange/ {
include /srv/zardasht-portal/deploy/nginx/omnl-portal-auth-current.conf.inc;
proxy_pass http://omnl_lxc_exchange/api/v1/exchange/;
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@@ -0,0 +1,3 @@
# Copy to /etc/nginx/omnl-portal-auth-current.conf.inc on deploy (generated with real secrets).
# proxy_set_header Authorization "Bearer <super-admin-key>";
# proxy_set_header X-OMNL-Portal-Auth "<internal-secret>";

View File

@@ -41,10 +41,11 @@ export function exchangeApi(path: string): string {
return proxiedServiceUrl(DBIS_EXCHANGE_URL, '/api/v1/exchange', path);
}
/** Bearer auth for OMNL mutating APIs (token load, transfers, swap settle). */
/** Bearer auth for OMNL mutating APIs — production portals inject via nginx; never embed operator keys in browser. */
export function omnlApiHeaders(json = true): Record<string, string> {
const headers: Record<string, string> = {};
if (json) headers['Content-Type'] = 'application/json';
if (import.meta.env.PROD) return headers;
const key = import.meta.env.VITE_OMNL_API_KEY?.trim();
if (key) headers.Authorization = `Bearer ${key}`;
return headers;

View File

@@ -16,3 +16,4 @@ export * from './webhooks/verifySignature';
export * from './hybx/HttpHybxClient';
export * from './reconciliation/ReconciliationStatus';
export * from './resilience/circuitBreaker';
export * from './omnl/omnl-api-auth';

View File

@@ -32,3 +32,4 @@ __exportStar(require("./webhooks/verifySignature"), exports);
__exportStar(require("./hybx/HttpHybxClient"), exports);
__exportStar(require("./reconciliation/ReconciliationStatus"), exports);
__exportStar(require("./resilience/circuitBreaker"), exports);
__exportStar(require("./omnl/omnl-api-auth"), exports);

View File

@@ -0,0 +1,44 @@
export type OmnlAuthRole = 'super_admin' | 'customer' | 'portal_internal' | 'legacy_operator';
export type OmnlPrincipal = {
role: OmnlAuthRole;
adminId?: string;
portalCtid?: number;
scopes?: string[];
};
export type SuperAdminEntry = {
id: string;
username: string;
displayName: string;
portalCtid: number;
portalName: string;
envKey: string;
fineractUser: string;
products: string[];
};
export type CustomerSecurityPolicy = {
version: string;
production: boolean;
neverExposeOperatorKeyInBrowser: boolean;
portalInjectAuthViaNginx: boolean;
requireApiKeyAllExchangeRoutes: boolean;
customerAllowedScopes: string[];
superAdminOnlyScopes: string[];
rateLimits: {
customerPerMinute: number;
superAdminPerMinute: number;
anonymousPerMinute: number;
};
corsOrigins: string[];
};
export declare function loadSuperAdminRegistry(): SuperAdminEntry[];
export declare function extractApiKey(req: {
headers: Record<string, unknown>;
query?: Record<string, unknown>;
}): string | undefined;
export declare function loadCustomerSecurityPolicy(): CustomerSecurityPolicy;
export declare function resolveOmnlPrincipal(req: {
headers: Record<string, unknown>;
query?: Record<string, unknown>;
}, key?: string): OmnlPrincipal | null;
export declare function principalHasScope(principal: OmnlPrincipal | null, scope: string): boolean;
export declare function isProductionSecurityMode(): boolean;

View File

@@ -0,0 +1,128 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadSuperAdminRegistry = loadSuperAdminRegistry;
exports.extractApiKey = extractApiKey;
exports.loadCustomerSecurityPolicy = loadCustomerSecurityPolicy;
exports.resolveOmnlPrincipal = resolveOmnlPrincipal;
exports.principalHasScope = principalHasScope;
exports.isProductionSecurityMode = isProductionSecurityMode;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
let superAdminCache = null;
function repoRoot() {
return process.env.OMNL_BANK_ROOT || process.env.REPO_ROOT || process.cwd();
}
function loadSuperAdminRegistry() {
if (superAdminCache)
return superAdminCache;
const configPath = process.env.OMNL_SUPER_ADMINS_CONFIG ||
path_1.default.join(repoRoot(), 'config/omnl-super-admins.v1.json');
try {
const raw = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
superAdminCache = raw.superAdmins ?? [];
}
catch {
superAdminCache = [];
}
return superAdminCache;
}
function extractApiKey(req) {
const auth = String(req.headers.authorization || '');
const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const headerKey = String(req.headers['x-api-key'] ?? '').trim();
const q = String(req.query?.access_token ?? '').trim();
return bearer || headerKey || q || undefined;
}
function customerKeys() {
const map = new Map();
const policy = loadCustomerSecurityPolicy();
const scopes = policy.customerAllowedScopes;
const raw = process.env.OMNL_CUSTOMER_API_KEYS?.trim();
if (raw) {
for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
const [key, scopeList] = part.split(':');
if (!key)
continue;
map.set(key.trim(), scopeList ? scopeList.split('|').map((s) => s.trim()).filter(Boolean) : scopes);
}
}
for (const envKey of ['OMNL_CUSTOMER_API_KEY_1', 'OMNL_CUSTOMER_API_KEY_2', 'OMNL_CUSTOMER_API_KEY_3']) {
const val = process.env[envKey]?.trim();
if (val)
map.set(val, scopes);
}
return map;
}
function loadCustomerSecurityPolicy() {
const configPath = process.env.OMNL_CUSTOMER_SECURITY_CONFIG ||
path_1.default.join(repoRoot(), 'config/omnl-customer-security.production.v1.json');
try {
return JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
}
catch {
return {
version: '1.0.0',
production: true,
neverExposeOperatorKeyInBrowser: true,
portalInjectAuthViaNginx: true,
requireApiKeyAllExchangeRoutes: true,
customerAllowedScopes: ['read', 'trade'],
superAdminOnlyScopes: ['settle', 'token_load', 'transfer', 'admin'],
rateLimits: { customerPerMinute: 30, superAdminPerMinute: 120, anonymousPerMinute: 0 },
corsOrigins: [],
};
}
}
function portalInternalOk(req) {
const secret = process.env.OMNL_PORTAL_INTERNAL_SECRET?.trim();
if (!secret)
return false;
const header = String(req.headers['x-omnl-portal-auth'] ?? '').trim();
return header === secret;
}
function resolveOmnlPrincipal(req, key) {
if (portalInternalOk(req)) {
return { role: 'portal_internal', scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'] };
}
const apiKey = key ?? extractApiKey(req);
if (!apiKey)
return null;
const legacy = process.env.OMNL_API_KEY?.trim();
if (legacy && apiKey === legacy) {
return { role: 'legacy_operator', scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'] };
}
for (const admin of loadSuperAdminRegistry()) {
const adminKey = process.env[admin.envKey]?.trim();
if (adminKey && apiKey === adminKey) {
return {
role: 'super_admin',
adminId: admin.id,
portalCtid: admin.portalCtid,
scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'],
};
}
}
const customers = customerKeys();
const customerScopes = customers.get(apiKey);
if (customerScopes) {
return { role: 'customer', scopes: customerScopes };
}
return null;
}
function principalHasScope(principal, scope) {
if (!principal)
return false;
if (principal.role === 'super_admin' || principal.role === 'legacy_operator' || principal.role === 'portal_internal') {
return true;
}
return (principal.scopes ?? []).includes(scope);
}
function isProductionSecurityMode() {
const policy = loadCustomerSecurityPolicy();
return (policy.production ||
process.env.OMNL_CUSTOMER_SECURITY_PRODUCTION === '1' ||
(process.env.NODE_ENV || '').toLowerCase() === 'production');
}

View File

@@ -16,3 +16,4 @@ export * from './webhooks/verifySignature';
export * from './hybx/HttpHybxClient';
export * from './reconciliation/ReconciliationStatus';
export * from './resilience/circuitBreaker';
export * from './omnl/omnl-api-auth';

View File

@@ -0,0 +1,173 @@
import fs from 'fs';
import path from 'path';
export type OmnlAuthRole = 'super_admin' | 'customer' | 'portal_internal' | 'legacy_operator';
export type OmnlPrincipal = {
role: OmnlAuthRole;
adminId?: string;
portalCtid?: number;
scopes?: string[];
};
export type SuperAdminEntry = {
id: string;
username: string;
displayName: string;
portalCtid: number;
portalName: string;
envKey: string;
fineractUser: string;
products: string[];
};
export type CustomerSecurityPolicy = {
version: string;
production: boolean;
neverExposeOperatorKeyInBrowser: boolean;
portalInjectAuthViaNginx: boolean;
requireApiKeyAllExchangeRoutes: boolean;
customerAllowedScopes: string[];
superAdminOnlyScopes: string[];
rateLimits: {
customerPerMinute: number;
superAdminPerMinute: number;
anonymousPerMinute: number;
};
corsOrigins: string[];
};
let superAdminCache: SuperAdminEntry[] | null = null;
function repoRoot(): string {
return process.env.OMNL_BANK_ROOT || process.env.REPO_ROOT || process.cwd();
}
export function loadSuperAdminRegistry(): SuperAdminEntry[] {
if (superAdminCache) return superAdminCache;
const configPath =
process.env.OMNL_SUPER_ADMINS_CONFIG ||
path.join(repoRoot(), 'config/omnl-super-admins.v1.json');
try {
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { superAdmins: SuperAdminEntry[] };
superAdminCache = raw.superAdmins ?? [];
} catch {
superAdminCache = [];
}
return superAdminCache;
}
export function extractApiKey(req: {
headers: Record<string, unknown>;
query?: Record<string, unknown>;
}): string | undefined {
const auth = String(req.headers.authorization || '');
const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const headerKey = String(req.headers['x-api-key'] ?? '').trim();
const q = String(req.query?.access_token ?? '').trim();
return bearer || headerKey || q || undefined;
}
function customerKeys(): Map<string, string[]> {
const map = new Map<string, string[]>();
const policy = loadCustomerSecurityPolicy();
const scopes = policy.customerAllowedScopes;
const raw = process.env.OMNL_CUSTOMER_API_KEYS?.trim();
if (raw) {
for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
const [key, scopeList] = part.split(':');
if (!key) continue;
map.set(
key.trim(),
scopeList ? scopeList.split('|').map((s) => s.trim()).filter(Boolean) : scopes,
);
}
}
for (const envKey of ['OMNL_CUSTOMER_API_KEY_1', 'OMNL_CUSTOMER_API_KEY_2', 'OMNL_CUSTOMER_API_KEY_3']) {
const val = process.env[envKey]?.trim();
if (val) map.set(val, scopes);
}
return map;
}
export function loadCustomerSecurityPolicy(): CustomerSecurityPolicy {
const configPath =
process.env.OMNL_CUSTOMER_SECURITY_CONFIG ||
path.join(repoRoot(), 'config/omnl-customer-security.production.v1.json');
try {
return JSON.parse(fs.readFileSync(configPath, 'utf8')) as CustomerSecurityPolicy;
} catch {
return {
version: '1.0.0',
production: true,
neverExposeOperatorKeyInBrowser: true,
portalInjectAuthViaNginx: true,
requireApiKeyAllExchangeRoutes: true,
customerAllowedScopes: ['read', 'trade'],
superAdminOnlyScopes: ['settle', 'token_load', 'transfer', 'admin'],
rateLimits: { customerPerMinute: 30, superAdminPerMinute: 120, anonymousPerMinute: 0 },
corsOrigins: [],
};
}
}
function portalInternalOk(req: { headers: Record<string, unknown> }): boolean {
const secret = process.env.OMNL_PORTAL_INTERNAL_SECRET?.trim();
if (!secret) return false;
const header = String(req.headers['x-omnl-portal-auth'] ?? '').trim();
return header === secret;
}
export function resolveOmnlPrincipal(
req: { headers: Record<string, unknown>; query?: Record<string, unknown> },
key?: string,
): OmnlPrincipal | null {
if (portalInternalOk(req)) {
return { role: 'portal_internal', scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'] };
}
const apiKey = key ?? extractApiKey(req);
if (!apiKey) return null;
const legacy = process.env.OMNL_API_KEY?.trim();
if (legacy && apiKey === legacy) {
return { role: 'legacy_operator', scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'] };
}
for (const admin of loadSuperAdminRegistry()) {
const adminKey = process.env[admin.envKey]?.trim();
if (adminKey && apiKey === adminKey) {
return {
role: 'super_admin',
adminId: admin.id,
portalCtid: admin.portalCtid,
scopes: ['read', 'trade', 'settle', 'token_load', 'transfer', 'admin'],
};
}
}
const customers = customerKeys();
const customerScopes = customers.get(apiKey);
if (customerScopes) {
return { role: 'customer', scopes: customerScopes };
}
return null;
}
export function principalHasScope(principal: OmnlPrincipal | null, scope: string): boolean {
if (!principal) return false;
if (principal.role === 'super_admin' || principal.role === 'legacy_operator' || principal.role === 'portal_internal') {
return true;
}
return (principal.scopes ?? []).includes(scope);
}
export function isProductionSecurityMode(): boolean {
const policy = loadCustomerSecurityPolicy();
return (
policy.production ||
process.env.OMNL_CUSTOMER_SECURITY_PRODUCTION === '1' ||
(process.env.NODE_ENV || '').toLowerCase() === 'production'
);
}

View File

@@ -42,6 +42,24 @@ if [ -f "$ENV_FILE" ]; then
set +a
fi
if [[ -f "$ENV_FILE" ]] && ! grep -q '^OMNL_SUPER_ADMIN_OFFICE24_KEY=' "$ENV_FILE" 2>/dev/null; then
log "Generating four super-admin API keys..."
bash "$REPO_DIR/scripts/deployment/seed-omnl-super-admin-keys.sh"
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
if [[ -f "$ENV_FILE" ]]; then
bash "$REPO_DIR/scripts/deployment/generate-portal-nginx-auth-snippet.sh" 5827 "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" || true
if [[ -f "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" ]]; then
sudo cp "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" /etc/nginx/omnl-portal-auth-current.conf.inc 2>/dev/null || \
cp "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" "$REPO_DIR/deploy/nginx/omnl-portal-auth-current.conf.inc" 2>/dev/null || true
sudo nginx -t 2>/dev/null && sudo systemctl reload nginx 2>/dev/null || true
fi
fi
build_pkg() {
local dir="$1"
log "Building $dir..."
@@ -60,7 +78,8 @@ cd "$REPO_DIR/frontend-dapp"
export VITE_TOKEN_AGGREGATION_URL="${VITE_TOKEN_AGGREGATION_URL:-/api/v1}"
export VITE_SETTLEMENT_MIDDLEWARE_URL="${VITE_SETTLEMENT_MIDDLEWARE_URL:-/settlement}"
export VITE_DBIS_EXCHANGE_URL="${VITE_DBIS_EXCHANGE_URL:-/exchange}"
export VITE_OMNL_API_KEY="${VITE_OMNL_API_KEY:-${OMNL_API_KEY:-}}"
# Never embed operator API keys in production frontend — nginx injects server-side.
unset VITE_OMNL_API_KEY
if command -v pnpm >/dev/null 2>&1; then
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
pnpm run build
@@ -84,7 +103,15 @@ start_svc() {
DBIS_EXCHANGE_CONFIG="$DBIS_EXCHANGE_CONFIG" \
OMNL_SUPPORTED_CHAINS_CONFIG="$OMNL_SUPPORTED_CHAINS_CONFIG" \
OMNL_M2_TOKEN_REGISTRY="$OMNL_M2_TOKEN_REGISTRY" \
OMNL_API_KEY="${OMNL_API_KEY:-}" \
OMNL_API_KEY="${OMNL_API_KEY:-${OMNL_SUPER_ADMIN_SECURE_KEY:-}}" \
OMNL_SUPER_ADMIN_OFFICE24_KEY="${OMNL_SUPER_ADMIN_OFFICE24_KEY:-}" \
OMNL_SUPER_ADMIN_EXCHANGE_KEY="${OMNL_SUPER_ADMIN_EXCHANGE_KEY:-}" \
OMNL_SUPER_ADMIN_SECURE_KEY="${OMNL_SUPER_ADMIN_SECURE_KEY:-}" \
OMNL_SUPER_ADMIN_FOREX_KEY="${OMNL_SUPER_ADMIN_FOREX_KEY:-}" \
OMNL_PORTAL_INTERNAL_SECRET="${OMNL_PORTAL_INTERNAL_SECRET:-}" \
OMNL_CUSTOMER_API_KEYS="${OMNL_CUSTOMER_API_KEYS:-}" \
OMNL_CUSTOMER_SECURITY_PRODUCTION="${OMNL_CUSTOMER_SECURITY_PRODUCTION:-1}" \
OMNL_BANK_ROOT="$REPO_DIR" \
OMNL_FINERACT_BASE_URL="${OMNL_FINERACT_BASE_URL:-${FINERACT_BASE_URL:-}}" \
OMNL_FINERACT_TENANT="${OMNL_FINERACT_TENANT:-${FINERACT_TENANT:-omnl}}" \
OMNL_FINERACT_USER="${OMNL_FINERACT_USER:-}" \

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Write nginx auth snippet for portal (injects super-admin Bearer + internal header server-side).
set -euo pipefail
REPO_DIR="${OMNL_BANK_ROOT:-$HOME/smom-dbis-138}"
ENV_FILE="${OMNL_BANK_ENV:-$REPO_DIR/.env}"
CTID="${1:-5827}"
OUT="${2:-$REPO_DIR/deploy/nginx/omnl-portal-auth-${CTID}.conf.inc}"
CONFIG="$REPO_DIR/config/omnl-super-admins.v1.json"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
ENV_KEY="$(node -e "
const fs = require('fs');
const cfg = JSON.parse(fs.readFileSync('$CONFIG', 'utf8'));
const ct = Number('$CTID');
const admin = cfg.superAdmins.find(a => a.portalCtid === ct);
if (!admin) process.exit(2);
console.log(admin.envKey);
")"
API_KEY="${!ENV_KEY:-${OMNL_API_KEY:-}}"
INTERNAL="${OMNL_PORTAL_INTERNAL_SECRET:-}"
if [[ -z "$API_KEY" ]]; then
echo "No API key for CT $CTID ($ENV_KEY unset)" >&2
exit 1
fi
mkdir -p "$(dirname "$OUT")"
cat >"$OUT" <<EOF
# Auto-generated — do not commit. CT $CTID portal upstream auth.
proxy_set_header Authorization "Bearer ${API_KEY}";
proxy_set_header X-OMNL-Portal-Auth "${INTERNAL}";
EOF
chmod 600 "$OUT"
echo "Wrote $OUT"

View File

@@ -6,6 +6,7 @@ CTID="${1:?CTID required}"
REPO_DIR="${OMNL_BANK_ROOT:-$HOME/smom-dbis-138}"
ENV_FILE="${OMNL_BANK_ENV:-$REPO_DIR/.env}"
PORTAL_ROOT="${OMNL_PORTAL_ROOT:-/srv/zardasht-portal}"
CONFIG="$REPO_DIR/config/omnl-super-admins.v1.json"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/omnl-pve-ssh.sh
source "${SCRIPT_DIR}/lib/omnl-pve-ssh.sh"
@@ -17,8 +18,32 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
FRAG="$(mktemp)"
grep -E '^(OMNL_FINERACT_|OMNL_API_KEY=|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OFFICE24_OPENING_M1_USD=)' "$ENV_FILE" >"$FRAG" || true
grep -E '^(OMNL_FINERACT_|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OFFICE24_OPENING_M1_USD=|OMNL_PORTAL_INTERNAL_SECRET=|OMNL_CUSTOMER_SECURITY_PRODUCTION=|JWT_SECRET=)' "$ENV_FILE" >"$FRAG" || true
ADMIN_ENV_KEY="$(node -e "
const fs = require('fs');
const cfg = JSON.parse(fs.readFileSync('$CONFIG', 'utf8'));
const ct = Number('$CTID');
const admin = cfg.superAdmins.find(a => a.portalCtid === ct);
if (!admin) process.exit(0);
console.log(admin.envKey);
" 2>/dev/null || true)"
if [[ -n "$ADMIN_ENV_KEY" ]]; then
ADMIN_VAL="${!ADMIN_ENV_KEY:-}"
if [[ -n "$ADMIN_VAL" ]]; then
echo "OMNL_API_KEY=${ADMIN_VAL}" >>"$FRAG"
log "Mapped CT $CTID$ADMIN_ENV_KEY as OMNL_API_KEY"
fi
elif [[ -n "${OMNL_API_KEY:-}" ]]; then
echo "OMNL_API_KEY=${OMNL_API_KEY}" >>"$FRAG"
fi
if [[ ! -s "$FRAG" ]]; then
log "No Fineract/API keys found in $ENV_FILE — skip CT $CTID"
@@ -29,6 +54,14 @@ fi
log "Pushing portal env keys to CT $CTID..."
omnl_pve_scp "$FRAG" "${OMNL_PVE_SSH_TARGET}:/tmp/omnl-portal-env.frag"
rm -f "$FRAG"
if [[ -n "$ADMIN_ENV_KEY" ]] && [[ -n "${!ADMIN_ENV_KEY:-}" ]]; then
bash "$SCRIPT_DIR/generate-portal-nginx-auth-snippet.sh" "$CTID" "$REPO_DIR/deploy/nginx/omnl-portal-auth-${CTID}.conf.inc"
omnl_pve_scp "$REPO_DIR/deploy/nginx/omnl-portal-auth-${CTID}.conf.inc" "${OMNL_PVE_SSH_TARGET}:/tmp/omnl-portal-auth.conf.inc"
omnl_pve_ssh "pct push ${CTID} /tmp/omnl-portal-auth.conf.inc ${PORTAL_ROOT}/deploy/nginx/omnl-portal-auth-current.conf.inc"
omnl_pve_ssh "pct exec ${CTID} -- chmod 600 ${PORTAL_ROOT}/deploy/nginx/omnl-portal-auth-current.conf.inc"
fi
omnl_pve_ssh "pct push ${CTID} /tmp/omnl-portal-env.frag /tmp/omnl-portal-env.frag"
omnl_pve_ssh "pct exec ${CTID} -- bash -s" <<EOF

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Generate four super-admin API keys + portal internal secret in .env (idempotent).
set -euo pipefail
REPO_DIR="${OMNL_BANK_ROOT:-$HOME/smom-dbis-138}"
ENV_FILE="${OMNL_BANK_ENV:-$REPO_DIR/.env}"
CONFIG="$REPO_DIR/config/omnl-super-admins.v1.json"
log() { echo "[$(date -Iseconds)] $*"; }
gen_key() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
else
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
fi
}
ensure_kv() {
local key="$1"
local val="$2"
if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then
log " $key already set — skip"
else
echo "${key}=${val}" >>"$ENV_FILE"
log " $key generated"
fi
}
if [[ ! -f "$ENV_FILE" ]]; then
touch "$ENV_FILE"
chmod 600 "$ENV_FILE"
fi
log "Seeding OMNL super-admin keys into $ENV_FILE"
node -e "
const fs = require('fs');
const cfg = JSON.parse(fs.readFileSync('$CONFIG', 'utf8'));
for (const a of cfg.superAdmins) console.log(a.envKey);
" | while read -r env_key; do
ensure_kv "$env_key" "$(gen_key)"
done
ensure_kv "OMNL_PORTAL_INTERNAL_SECRET" "$(gen_key)"
ensure_kv "OMNL_CUSTOMER_SECURITY_PRODUCTION" "1"
ensure_kv "OMNL_REQUIRE_API_KEY" "1"
if ! grep -q '^JWT_SECRET=' "$ENV_FILE" 2>/dev/null; then
ensure_kv "JWT_SECRET" "$(gen_key)"
fi
log "Super-admin key env vars ready. Map each LXC CTID to its portal key via push-portal-env-to-lxc.sh"

View File

@@ -8,19 +8,7 @@ Object.defineProperty(exports, "exchangePort", { enumerable: true, get: function
const settlement_1 = require("../../adapters/settlement");
const token_aggregation_1 = require("../../adapters/token-aggregation");
const swap_monitor_1 = require("../../store/swap-monitor");
function apiKey(req) {
return req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key') || undefined;
}
function requireKey(req, res) {
const cfg = (0, config_1.loadExchangeConfig)();
if (!cfg.production.requireApiKey)
return true;
const key = apiKey(req);
if (key && key === process.env.OMNL_API_KEY)
return true;
res.status(401).json({ error: 'Unauthorized' });
return false;
}
const auth_1 = require("../../middleware/auth");
function createExchangeRouter() {
const router = (0, express_1.Router)();
const cfg = (0, config_1.loadExchangeConfig)();
@@ -36,7 +24,9 @@ function createExchangeRouter() {
status: 'ok',
});
});
router.get('/tokens', (_req, res) => {
router.get('/tokens', (req, res) => {
if (!(0, auth_1.requireRead)(req, res))
return;
const tokens = (0, config_1.loadExchangeTokens)();
res.json({
chainId: cfg.chainId,
@@ -46,11 +36,15 @@ function createExchangeRouter() {
routers: cfg.routers,
});
});
router.get('/tokens/capabilities', (_req, res) => {
router.get('/tokens/capabilities', (req, res) => {
if (!(0, auth_1.requireRead)(req, res))
return;
const registry = (0, config_1.loadM2TokenRegistry)();
res.json(registry);
});
router.get('/providers', async (_req, res) => {
router.get('/providers', async (req, res) => {
if (!(0, auth_1.requireRead)(req, res))
return;
try {
res.json(await (0, token_aggregation_1.fetchProviderCapabilities)(cfg.chainId));
}
@@ -59,6 +53,8 @@ function createExchangeRouter() {
}
});
router.get('/quote', async (req, res) => {
if (!(0, auth_1.requireRead)(req, res))
return;
try {
const tokenIn = String(req.query.tokenIn || '');
const tokenOut = String(req.query.tokenOut || '');
@@ -91,6 +87,8 @@ function createExchangeRouter() {
}
});
router.post('/plan', async (req, res) => {
if (!(0, auth_1.requireTrade)(req, res))
return;
try {
const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body;
if (!tokenIn || !tokenOut || !amountIn) {
@@ -113,6 +111,8 @@ function createExchangeRouter() {
}
});
router.post('/execute-plan', async (req, res) => {
if (!(0, auth_1.requireTrade)(req, res))
return;
try {
const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body;
if (!tokenIn || !tokenOut || !amountIn || !recipient) {
@@ -135,6 +135,8 @@ function createExchangeRouter() {
}
});
router.get('/ledgers', async (req, res) => {
if (!(0, auth_1.requireRead)(req, res))
return;
try {
const wallet = req.query.wallet ? String(req.query.wallet) : undefined;
const serverKey = process.env.OMNL_API_KEY;
@@ -165,10 +167,14 @@ function createExchangeRouter() {
res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/swap/monitor', (_req, res) => {
res.json({ stats: (0, swap_monitor_1.swapStats)(), swaps: (0, swap_monitor_1.listSwaps)(Number(_req.query.limit ?? 50)) });
router.get('/swap/monitor', (req, res) => {
if (!(0, auth_1.requireSuperAdmin)(req, res))
return;
res.json({ stats: (0, swap_monitor_1.swapStats)(), swaps: (0, swap_monitor_1.listSwaps)(Number(req.query.limit ?? 50)) });
});
router.post('/swap/record', (req, res) => {
if (!(0, auth_1.requireTrade)(req, res))
return;
const body = req.body;
const rec = (0, swap_monitor_1.recordSwap)({
officeId: cfg.officeId,
@@ -188,6 +194,8 @@ function createExchangeRouter() {
res.json(rec);
});
router.patch('/swap/record/:id', (req, res) => {
if (!(0, auth_1.requireSuperAdmin)(req, res))
return;
const updated = (0, swap_monitor_1.updateSwap)(req.params.id, req.body);
if (!updated) {
res.status(404).json({ error: 'Not found' });
@@ -196,7 +204,7 @@ function createExchangeRouter() {
res.json(updated);
});
router.post('/swap/settle', async (req, res) => {
if (!requireKey(req, res))
if (!(0, auth_1.requireSuperAdmin)(req, res))
return;
if (!cfg.production.postSwapSettlementAudit) {
res.status(403).json({ error: 'postSwapSettlementAudit disabled' });
@@ -218,7 +226,7 @@ function createExchangeRouter() {
recipientAddress: String(recipientAddress ?? ''),
remittanceInfo: `DBIS Exchange M2 swap settlement Office ${cfg.officeId}`,
tokenLineId: lineId,
apiKey: apiKey(req),
apiKey: (0, auth_1.apiKey)(req),
});
if (swapId)
(0, swap_monitor_1.updateSwap)(String(swapId), { settlementRef: audit.settlementId, status: 'CONFIRMED' });
@@ -229,7 +237,7 @@ function createExchangeRouter() {
}
});
router.post('/token-load', async (req, res) => {
if (!requireKey(req, res))
if (!(0, auth_1.requireSuperAdmin)(req, res))
return;
try {
const body = req.body;
@@ -244,7 +252,7 @@ function createExchangeRouter() {
amount: String(body.amount ?? '0'),
recipientAddress: String(body.recipientAddress ?? ''),
currency: String(body.currency ?? token?.currencyCode ?? 'USD'),
apiKey: apiKey(req) ?? process.env.OMNL_API_KEY,
apiKey: (0, auth_1.apiKey)(req) ?? process.env.OMNL_API_KEY,
});
res.status(result.phase === 'FAILED' ? 422 : 200).json(result);
}
@@ -253,7 +261,7 @@ function createExchangeRouter() {
}
});
router.post('/transfer/internal', async (req, res) => {
if (!requireKey(req, res))
if (!(0, auth_1.requireSuperAdmin)(req, res))
return;
try {
const body = req.body;
@@ -264,7 +272,7 @@ function createExchangeRouter() {
amount: String(body.amount ?? '0'),
recipientAddress: String(body.recipientAddress ?? ''),
senderAddress: body.senderAddress ? String(body.senderAddress) : undefined,
apiKey: apiKey(req) ?? process.env.OMNL_API_KEY,
apiKey: (0, auth_1.apiKey)(req) ?? process.env.OMNL_API_KEY,
});
res.status(result.phase === 'FAILED' ? 422 : 200).json(result);
}
@@ -273,7 +281,7 @@ function createExchangeRouter() {
}
});
router.post('/transfer/external', async (req, res) => {
if (!requireKey(req, res))
if (!(0, auth_1.requireSuperAdmin)(req, res))
return;
try {
const body = req.body;
@@ -287,7 +295,7 @@ function createExchangeRouter() {
beneficiaryName: body.beneficiaryName ? String(body.beneficiaryName) : undefined,
currency: body.currency ? String(body.currency) : undefined,
remittanceInfo: body.remittanceInfo ? String(body.remittanceInfo) : undefined,
apiKey: apiKey(req) ?? process.env.OMNL_API_KEY,
apiKey: (0, auth_1.apiKey)(req) ?? process.env.OMNL_API_KEY,
});
res.status(result.phase === 'FAILED' ? 422 : 200).json(result);
}

View File

@@ -0,0 +1,40 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.apiKey = apiKey;
exports.requireScope = requireScope;
exports.requireSuperAdmin = requireSuperAdmin;
exports.requireRead = requireRead;
exports.requireTrade = requireTrade;
const integration_foundation_1 = require("@dbis/integration-foundation");
const config_1 = require("../config");
function apiKey(req) {
return (0, integration_foundation_1.extractApiKey)(req);
}
function requireScope(req, res, scope) {
const cfg = (0, config_1.loadExchangeConfig)();
if (!cfg.production.requireApiKey && !(0, integration_foundation_1.isProductionSecurityMode)())
return true;
const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)();
if (policy.requireApiKeyAllExchangeRoutes || cfg.production.requireApiKey) {
const principal = (0, integration_foundation_1.resolveOmnlPrincipal)(req);
if (!principal) {
res.status(401).json({ error: 'Unauthorized', hint: 'Valid customer or super-admin API key required' });
return false;
}
if (!(0, integration_foundation_1.principalHasScope)(principal, scope)) {
res.status(403).json({ error: 'Forbidden', scope, role: principal.role });
return false;
}
return true;
}
return true;
}
function requireSuperAdmin(req, res) {
return requireScope(req, res, 'admin');
}
function requireRead(req, res) {
return requireScope(req, res, 'read');
}
function requireTrade(req, res) {
return requireScope(req, res, 'trade');
}

View File

@@ -0,0 +1,28 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.exchangeRateLimiter = void 0;
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const integration_foundation_1 = require("@dbis/integration-foundation");
function asHandler(h) {
return h;
}
exports.exchangeRateLimiter = asHandler((0, express_rate_limit_1.default)({
windowMs: 60_000,
max: (req) => {
if (!(0, integration_foundation_1.isProductionSecurityMode)())
return 200;
const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)();
const principal = (0, integration_foundation_1.resolveOmnlPrincipal)(req);
if (!principal)
return policy.rateLimits.anonymousPerMinute;
if (principal.role === 'customer')
return policy.rateLimits.customerPerMinute;
return policy.rateLimits.superAdminPerMinute;
},
message: { error: 'Too many requests — rate limit exceeded' },
standardHeaders: true,
legacyHeaders: false,
}));

View File

@@ -6,12 +6,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.startServer = startServer;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const integration_foundation_1 = require("@dbis/integration-foundation");
const exchange_1 = require("./api/routes/exchange");
const rate_limit_1 = require("./middleware/rate-limit");
function corsOptions() {
const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)();
const origins = policy.corsOrigins.filter(Boolean);
if (!origins.length)
return {};
return {
origin(origin, cb) {
if (!origin || origins.includes(origin))
cb(null, true);
else
cb(new Error('CORS not allowed'));
},
};
}
function startServer() {
const app = (0, express_1.default)();
app.use((0, cors_1.default)());
app.use((0, cors_1.default)(corsOptions()));
app.use(express_1.default.json());
app.use('/api/v1/exchange', (0, exchange_1.createExchangeRouter)());
app.use('/api/v1/exchange', rate_limit_1.exchangeRateLimiter, (0, exchange_1.createExchangeRouter)());
const p = (0, exchange_1.exchangePort)();
app.listen(p, () => {
console.log(`DBIS Exchange (Pancake-style) on http://localhost:${p}/api/v1/exchange`);

View File

@@ -8,12 +8,14 @@
"name": "dbis-exchange",
"version": "1.0.0",
"dependencies": {
"@dbis/integration-foundation": "file:../../packages/integration-foundation",
"@dbis/settlement-core": "file:../../packages/settlement-core",
"axios": "^1.15.2",
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"ethers": "^6.16.0",
"express": "^5.1.0",
"express-rate-limit": "^8.5.1",
"uuid": "^11.1.0"
},
"devDependencies": {
@@ -25,6 +27,14 @@
"typescript": "^5.9.3"
}
},
"../../packages/integration-foundation": {
"name": "@dbis/integration-foundation",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^20.11.0",
"typescript": "^5.4.0"
}
},
"../../packages/settlement-core": {
"name": "@dbis/settlement-core",
"version": "0.1.0",
@@ -52,6 +62,10 @@
"node": ">=12"
}
},
"node_modules/@dbis/integration-foundation": {
"resolved": "../../packages/integration-foundation",
"link": true
},
"node_modules/@dbis/settlement-core": {
"resolved": "../../packages/settlement-core",
"link": true
@@ -716,6 +730,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -964,6 +996,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View File

@@ -9,12 +9,14 @@
"dev": "ts-node src/index.ts"
},
"dependencies": {
"@dbis/integration-foundation": "file:../../packages/integration-foundation",
"@dbis/settlement-core": "file:../../packages/settlement-core",
"axios": "^1.15.2",
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"ethers": "^6.16.0",
"express": "^5.1.0",
"express-rate-limit": "^8.5.1",
"uuid": "^11.1.0"
},
"devDependencies": {

View File

@@ -24,19 +24,7 @@ import {
fetchProviderCapabilities,
} from '../../adapters/token-aggregation';
import { listSwaps, recordSwap, swapStats, updateSwap } from '../../store/swap-monitor';
function apiKey(req: Request): string | undefined {
return req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key') || undefined;
}
function requireKey(req: Request, res: Response): boolean {
const cfg = loadExchangeConfig();
if (!cfg.production.requireApiKey) return true;
const key = apiKey(req);
if (key && key === process.env.OMNL_API_KEY) return true;
res.status(401).json({ error: 'Unauthorized' });
return false;
}
import { apiKey, requireRead, requireSuperAdmin, requireTrade } from '../../middleware/auth';
export function createExchangeRouter(): Router {
const router = Router();
@@ -55,7 +43,8 @@ export function createExchangeRouter(): Router {
});
});
router.get('/tokens', (_req, res) => {
router.get('/tokens', (req, res) => {
if (!requireRead(req, res)) return;
const tokens = loadExchangeTokens();
res.json({
chainId: cfg.chainId,
@@ -66,12 +55,14 @@ export function createExchangeRouter(): Router {
});
});
router.get('/tokens/capabilities', (_req, res) => {
router.get('/tokens/capabilities', (req, res) => {
if (!requireRead(req, res)) return;
const registry = loadM2TokenRegistry();
res.json(registry);
});
router.get('/providers', async (_req, res) => {
router.get('/providers', async (req, res) => {
if (!requireRead(req, res)) return;
try {
res.json(await fetchProviderCapabilities(cfg.chainId));
} catch (e) {
@@ -80,6 +71,7 @@ export function createExchangeRouter(): Router {
});
router.get('/quote', async (req, res) => {
if (!requireRead(req, res)) return;
try {
const tokenIn = String(req.query.tokenIn || '');
const tokenOut = String(req.query.tokenOut || '');
@@ -112,6 +104,7 @@ export function createExchangeRouter(): Router {
});
router.post('/plan', async (req, res) => {
if (!requireTrade(req, res)) return;
try {
const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body as Record<string, unknown>;
if (!tokenIn || !tokenOut || !amountIn) {
@@ -134,6 +127,7 @@ export function createExchangeRouter(): Router {
});
router.post('/execute-plan', async (req, res) => {
if (!requireTrade(req, res)) return;
try {
const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body as Record<string, unknown>;
if (!tokenIn || !tokenOut || !amountIn || !recipient) {
@@ -156,6 +150,7 @@ export function createExchangeRouter(): Router {
});
router.get('/ledgers', async (req, res) => {
if (!requireRead(req, res)) return;
try {
const wallet = req.query.wallet ? String(req.query.wallet) : undefined;
const serverKey = process.env.OMNL_API_KEY;
@@ -187,11 +182,13 @@ export function createExchangeRouter(): Router {
}
});
router.get('/swap/monitor', (_req, res) => {
res.json({ stats: swapStats(), swaps: listSwaps(Number(_req.query.limit ?? 50)) });
router.get('/swap/monitor', (req, res) => {
if (!requireSuperAdmin(req, res)) return;
res.json({ stats: swapStats(), swaps: listSwaps(Number(req.query.limit ?? 50)) });
});
router.post('/swap/record', (req, res) => {
if (!requireTrade(req, res)) return;
const body = req.body as Record<string, unknown>;
const rec = recordSwap({
officeId: cfg.officeId,
@@ -212,6 +209,7 @@ export function createExchangeRouter(): Router {
});
router.patch('/swap/record/:id', (req, res) => {
if (!requireSuperAdmin(req, res)) return;
const updated = updateSwap(req.params.id, req.body as Record<string, unknown>);
if (!updated) {
res.status(404).json({ error: 'Not found' });
@@ -221,7 +219,7 @@ export function createExchangeRouter(): Router {
});
router.post('/swap/settle', async (req, res) => {
if (!requireKey(req, res)) return;
if (!requireSuperAdmin(req, res)) return;
if (!cfg.production.postSwapSettlementAudit) {
res.status(403).json({ error: 'postSwapSettlementAudit disabled' });
return;
@@ -253,7 +251,7 @@ export function createExchangeRouter(): Router {
});
router.post('/token-load', async (req, res) => {
if (!requireKey(req, res)) return;
if (!requireSuperAdmin(req, res)) return;
try {
const body = req.body as Record<string, unknown>;
const tokens = loadExchangeTokens();
@@ -276,7 +274,7 @@ export function createExchangeRouter(): Router {
});
router.post('/transfer/internal', async (req, res) => {
if (!requireKey(req, res)) return;
if (!requireSuperAdmin(req, res)) return;
try {
const body = req.body as Record<string, unknown>;
const result = await requestInternalTransfer({
@@ -295,7 +293,7 @@ export function createExchangeRouter(): Router {
});
router.post('/transfer/external', async (req, res) => {
if (!requireKey(req, res)) return;
if (!requireSuperAdmin(req, res)) return;
try {
const body = req.body as Record<string, unknown>;
const result = await requestExternalTransfer({

View File

@@ -0,0 +1,45 @@
import type { Request, Response } from 'express';
import {
extractApiKey,
isProductionSecurityMode,
loadCustomerSecurityPolicy,
principalHasScope,
resolveOmnlPrincipal,
} from '@dbis/integration-foundation';
import { loadExchangeConfig } from '../config';
export function apiKey(req: Request): string | undefined {
return extractApiKey(req);
}
export function requireScope(req: Request, res: Response, scope: string): boolean {
const cfg = loadExchangeConfig();
if (!cfg.production.requireApiKey && !isProductionSecurityMode()) return true;
const policy = loadCustomerSecurityPolicy();
if (policy.requireApiKeyAllExchangeRoutes || cfg.production.requireApiKey) {
const principal = resolveOmnlPrincipal(req);
if (!principal) {
res.status(401).json({ error: 'Unauthorized', hint: 'Valid customer or super-admin API key required' });
return false;
}
if (!principalHasScope(principal, scope)) {
res.status(403).json({ error: 'Forbidden', scope, role: principal.role });
return false;
}
return true;
}
return true;
}
export function requireSuperAdmin(req: Request, res: Response): boolean {
return requireScope(req, res, 'admin');
}
export function requireRead(req: Request, res: Response): boolean {
return requireScope(req, res, 'read');
}
export function requireTrade(req: Request, res: Response): boolean {
return requireScope(req, res, 'trade');
}

View File

@@ -0,0 +1,24 @@
import rateLimit from 'express-rate-limit';
import type { RequestHandler } from 'express';
import { isProductionSecurityMode, loadCustomerSecurityPolicy, resolveOmnlPrincipal } from '@dbis/integration-foundation';
function asHandler(h: ReturnType<typeof rateLimit>): RequestHandler {
return h as unknown as RequestHandler;
}
export const exchangeRateLimiter = asHandler(
rateLimit({
windowMs: 60_000,
max: (req) => {
if (!isProductionSecurityMode()) return 200;
const policy = loadCustomerSecurityPolicy();
const principal = resolveOmnlPrincipal(req);
if (!principal) return policy.rateLimits.anonymousPerMinute;
if (principal.role === 'customer') return policy.rateLimits.customerPerMinute;
return policy.rateLimits.superAdminPerMinute;
},
message: { error: 'Too many requests — rate limit exceeded' },
standardHeaders: true,
legacyHeaders: false,
}),
);

View File

@@ -1,12 +1,26 @@
import express from 'express';
import cors from 'cors';
import { loadCustomerSecurityPolicy } from '@dbis/integration-foundation';
import { createExchangeRouter, exchangePort } from './api/routes/exchange';
import { exchangeRateLimiter } from './middleware/rate-limit';
function corsOptions(): cors.CorsOptions {
const policy = loadCustomerSecurityPolicy();
const origins = policy.corsOrigins.filter(Boolean);
if (!origins.length) return {};
return {
origin(origin, cb) {
if (!origin || origins.includes(origin)) cb(null, true);
else cb(new Error('CORS not allowed'));
},
};
}
export function startServer(): void {
const app = express();
app.use(cors());
app.use(cors(corsOptions()));
app.use(express.json());
app.use('/api/v1/exchange', createExchangeRouter());
app.use('/api/v1/exchange', exchangeRateLimiter, createExchangeRouter());
const p = exchangePort();
app.listen(p, () => {
console.log(`DBIS Exchange (Pancake-style) on http://localhost:${p}/api/v1/exchange`);

View File

@@ -11,16 +11,7 @@ const office_scope_1 = require("../../workflows/office-scope");
const settlement_store_1 = require("../../store/settlement-store");
const swift_inbound_1 = require("../../workflows/swift-inbound");
const fineract_1 = require("../../adapters/fineract");
function requireApiKey(req, res) {
const cfg = (0, config_1.loadConfig)();
if (!cfg.production.requireApiKey)
return true;
const key = req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key');
if (key && key === process.env.OMNL_API_KEY)
return true;
res.status(401).json({ error: 'Unauthorized' });
return false;
}
const auth_1 = require("../../middleware/auth");
async function respondPublicMoneySupply(res, officeId) {
const cfg = (0, config_1.loadConfig)();
const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
@@ -117,7 +108,7 @@ function createSettlementRouter() {
router.get('/public/money-supply', (_req, res) => respondPublicMoneySupply(res, officeId));
router.get('/money-supply/public', (_req, res) => respondPublicMoneySupply(res, officeId));
router.get('/money-supply', async (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
res.json(await (0, external_transfer_1.getMoneySupply)(officeId));
@@ -127,7 +118,7 @@ function createSettlementRouter() {
}
});
router.get('/money-supply/:officeIdParam', async (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
const requested = parseInt(req.params.officeIdParam, 10);
if (!Number.isFinite(requested)) {
@@ -142,7 +133,7 @@ function createSettlementRouter() {
}
});
router.get('/settlements', (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
res.json({
officeId,
@@ -150,7 +141,7 @@ function createSettlementRouter() {
});
});
router.get('/settlements/:id', (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
const rec = (0, external_transfer_1.getSettlementStatus)(req.params.id);
if (!rec) {
@@ -160,7 +151,7 @@ function createSettlementRouter() {
res.json(rec);
});
router.post('/external-transfer', async (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const merged = (0, office_scope_1.bindOffice24)(req.body);
@@ -174,7 +165,7 @@ function createSettlementRouter() {
}
});
router.post('/swift/inbound', async (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const raw = typeof req.body === 'string' ? req.body : req.body?.raw;
@@ -190,7 +181,7 @@ function createSettlementRouter() {
}
});
router.post('/trade-finance/:instrument', async (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
const instrument = req.params.instrument.toUpperCase();
if (!['SBLC', 'DLC', 'BG', 'LS'].includes(instrument)) {
@@ -217,7 +208,7 @@ function createSettlementRouter() {
}
});
router.post('/convert/fiat-to-crypto', async (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const body = req.body;
@@ -240,7 +231,7 @@ function createSettlementRouter() {
}
});
router.post('/token-load', async (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const body = req.body;
@@ -259,7 +250,7 @@ function createSettlementRouter() {
}
});
router.post('/transfer/internal', async (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const body = req.body;
@@ -280,7 +271,7 @@ function createSettlementRouter() {
}
});
router.post('/transfer/external', async (req, res) => {
if (!requireApiKey(req, res))
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const body = req.body;

View File

@@ -0,0 +1,20 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.requireApiKey = requireApiKey;
const integration_foundation_1 = require("@dbis/integration-foundation");
const config_1 = require("../config");
function requireApiKey(req, res) {
const cfg = (0, config_1.loadConfig)();
if (!cfg.production.requireApiKey)
return true;
const principal = (0, integration_foundation_1.resolveOmnlPrincipal)(req);
if (!principal) {
res.status(401).json({ error: 'Unauthorized' });
return false;
}
if (principal.role === 'customer') {
res.status(403).json({ error: 'Forbidden', hint: 'Settlement APIs require super-admin credentials' });
return false;
}
return true;
}

View File

@@ -0,0 +1,28 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.settlementRateLimiter = void 0;
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const integration_foundation_1 = require("@dbis/integration-foundation");
function asHandler(h) {
return h;
}
exports.settlementRateLimiter = asHandler((0, express_rate_limit_1.default)({
windowMs: 60_000,
max: (req) => {
if (!(0, integration_foundation_1.isProductionSecurityMode)())
return 200;
const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)();
const principal = (0, integration_foundation_1.resolveOmnlPrincipal)(req);
if (!principal)
return policy.rateLimits.anonymousPerMinute;
if (principal.role === 'customer')
return policy.rateLimits.customerPerMinute;
return policy.rateLimits.superAdminPerMinute;
},
message: { error: 'Too many requests — rate limit exceeded' },
standardHeaders: true,
legacyHeaders: false,
}));

View File

@@ -7,14 +7,30 @@ exports.createApp = createApp;
exports.startServer = startServer;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const integration_foundation_1 = require("@dbis/integration-foundation");
const settlement_1 = require("./api/routes/settlement");
const rate_limit_1 = require("./middleware/rate-limit");
const config_1 = require("./config");
function corsOptions() {
const policy = (0, integration_foundation_1.loadCustomerSecurityPolicy)();
const origins = policy.corsOrigins.filter(Boolean);
if (!origins.length)
return {};
return {
origin(origin, cb) {
if (!origin || origins.includes(origin))
cb(null, true);
else
cb(new Error('CORS not allowed'));
},
};
}
function createApp() {
const app = (0, express_1.default)();
app.use((0, cors_1.default)());
app.use((0, cors_1.default)(corsOptions()));
app.use(express_1.default.json({ limit: '2mb' }));
app.use(express_1.default.text({ type: 'text/plain', limit: '2mb' }));
app.use('/api/v1/settlement', (0, settlement_1.createSettlementRouter)());
app.use('/api/v1/settlement', rate_limit_1.settlementRateLimiter, (0, settlement_1.createSettlementRouter)());
return app;
}
function startServer() {

View File

@@ -14,6 +14,7 @@
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"express": "^5.1.0",
"express-rate-limit": "^8.5.1",
"uuid": "^11.1.0"
},
"devDependencies": {
@@ -649,6 +650,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -897,6 +916,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View File

@@ -16,6 +16,7 @@
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"express": "^5.1.0",
"express-rate-limit": "^8.5.1",
"uuid": "^11.1.0"
},
"devDependencies": {

View File

@@ -13,15 +13,7 @@ import { bindOffice24 } from '../../workflows/office-scope';
import { settlementStore } from '../../store/settlement-store';
import { processSwiftInbound } from '../../workflows/swift-inbound';
import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract';
function requireApiKey(req: Request, res: Response): boolean {
const cfg = loadConfig();
if (!cfg.production.requireApiKey) return true;
const key = req.header('Authorization')?.replace(/^Bearer\s+/i, '') || req.header('X-API-Key');
if (key && key === process.env.OMNL_API_KEY) return true;
res.status(401).json({ error: 'Unauthorized' });
return false;
}
import { requireApiKey } from '../../middleware/auth';
async function respondPublicMoneySupply(res: Response, officeId: number): Promise<void> {
const cfg = loadConfig();

View File

@@ -0,0 +1,18 @@
import type { Request, Response } from 'express';
import { resolveOmnlPrincipal } from '@dbis/integration-foundation';
import { loadConfig } from '../config';
export function requireApiKey(req: Request, res: Response): boolean {
const cfg = loadConfig();
if (!cfg.production.requireApiKey) return true;
const principal = resolveOmnlPrincipal(req);
if (!principal) {
res.status(401).json({ error: 'Unauthorized' });
return false;
}
if (principal.role === 'customer') {
res.status(403).json({ error: 'Forbidden', hint: 'Settlement APIs require super-admin credentials' });
return false;
}
return true;
}

View File

@@ -0,0 +1,24 @@
import rateLimit from 'express-rate-limit';
import type { RequestHandler } from 'express';
import { isProductionSecurityMode, loadCustomerSecurityPolicy, resolveOmnlPrincipal } from '@dbis/integration-foundation';
function asHandler(h: ReturnType<typeof rateLimit>): RequestHandler {
return h as unknown as RequestHandler;
}
export const settlementRateLimiter = asHandler(
rateLimit({
windowMs: 60_000,
max: (req) => {
if (!isProductionSecurityMode()) return 200;
const policy = loadCustomerSecurityPolicy();
const principal = resolveOmnlPrincipal(req);
if (!principal) return policy.rateLimits.anonymousPerMinute;
if (principal.role === 'customer') return policy.rateLimits.customerPerMinute;
return policy.rateLimits.superAdminPerMinute;
},
message: { error: 'Too many requests — rate limit exceeded' },
standardHeaders: true,
legacyHeaders: false,
}),
);

View File

@@ -1,14 +1,28 @@
import express from 'express';
import cors from 'cors';
import { loadCustomerSecurityPolicy } from '@dbis/integration-foundation';
import { createSettlementRouter } from './api/routes/settlement';
import { settlementRateLimiter } from './middleware/rate-limit';
import { port } from './config';
function corsOptions(): cors.CorsOptions {
const policy = loadCustomerSecurityPolicy();
const origins = policy.corsOrigins.filter(Boolean);
if (!origins.length) return {};
return {
origin(origin, cb) {
if (!origin || origins.includes(origin)) cb(null, true);
else cb(new Error('CORS not allowed'));
},
};
}
export function createApp() {
const app = express();
app.use(cors());
app.use(cors(corsOptions()));
app.use(express.json({ limit: '2mb' }));
app.use(express.text({ type: 'text/plain', limit: '2mb' }));
app.use('/api/v1/settlement', createSettlementRouter());
app.use('/api/v1/settlement', settlementRateLimiter, createSettlementRouter());
return app;
}

View File

@@ -1,4 +1,5 @@
import type { Request, Response, NextFunction } from 'express';
import { resolveOmnlPrincipal } from '@dbis/integration-foundation';
function extractBearerOrQuery(req: Request, key: string): boolean {
const auth = String(req.headers.authorization || '');
@@ -9,6 +10,8 @@ function extractBearerOrQuery(req: Request, key: string): boolean {
}
function hasOmnlOperatorAuth(req: Request): boolean {
const principal = resolveOmnlPrincipal(req);
if (principal && principal.role !== 'customer') return true;
const apiKey = process.env.OMNL_API_KEY?.trim();
if (apiKey && extractBearerOrQuery(req, apiKey)) return true;
const dashKey = process.env.OMNL_DASHBOARD_TOKEN?.trim();
@@ -69,18 +72,14 @@ export function omnlRequireApiKeyInProduction(req: Request, res: Response, next:
return;
}
const key = process.env.OMNL_API_KEY?.trim();
if (!key) {
if (!key && !process.env.OMNL_SUPER_ADMIN_OFFICE24_KEY) {
res.status(503).json({
error: 'OMNL_API_KEY required in production',
hint: 'Set OMNL_API_KEY and pass Authorization: Bearer <key>',
error: 'OMNL super-admin API keys required in production',
hint: 'Run scripts/deployment/seed-omnl-super-admin-keys.sh',
});
return;
}
if (extractBearerOrQuery(req, key)) {
next();
return;
}
if (isComplianceConsolePath(req.path) && hasOmnlOperatorAuth(req)) {
if (hasOmnlOperatorAuth(req)) {
next();
return;
}