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
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:
@@ -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 5825–5828) ===
|
||||
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=
|
||||
|
||||
30
config/omnl-customer-security.production.v1.json
Normal file
30
config/omnl-customer-security.production.v1.json
Normal 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."
|
||||
}
|
||||
40
config/omnl-fineract-roles.v1.json
Normal file
40
config/omnl-fineract-roles.v1.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
47
config/omnl-super-admins.v1.json
Normal file
47
config/omnl-super-admins.v1.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "Four OMNL super-admin operators — one per banking portal LXC (5825–5828). 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."
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
3
deploy/nginx/omnl-portal-auth-current.conf.inc.example
Normal file
3
deploy/nginx/omnl-portal-auth-current.conf.inc.example
Normal 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>";
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
44
packages/integration-foundation/dist/omnl/omnl-api-auth.d.ts
vendored
Normal file
44
packages/integration-foundation/dist/omnl/omnl-api-auth.d.ts
vendored
Normal 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;
|
||||
128
packages/integration-foundation/dist/omnl/omnl-api-auth.js
vendored
Normal file
128
packages/integration-foundation/dist/omnl/omnl-api-auth.js
vendored
Normal 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');
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
173
packages/integration-foundation/src/omnl/omnl-api-auth.ts
Normal file
173
packages/integration-foundation/src/omnl/omnl-api-auth.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
@@ -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:-}" \
|
||||
|
||||
47
scripts/deployment/generate-portal-nginx-auth-snippet.sh
Normal file
47
scripts/deployment/generate-portal-nginx-auth-snippet.sh
Normal 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"
|
||||
@@ -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
|
||||
|
||||
53
scripts/deployment/seed-omnl-super-admin-keys.sh
Normal file
53
scripts/deployment/seed-omnl-super-admin-keys.sh
Normal 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"
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
40
services/dbis-exchange/dist/middleware/auth.js
vendored
Normal file
40
services/dbis-exchange/dist/middleware/auth.js
vendored
Normal 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');
|
||||
}
|
||||
28
services/dbis-exchange/dist/middleware/rate-limit.js
vendored
Normal file
28
services/dbis-exchange/dist/middleware/rate-limit.js
vendored
Normal 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,
|
||||
}));
|
||||
20
services/dbis-exchange/dist/server.js
vendored
20
services/dbis-exchange/dist/server.js
vendored
@@ -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`);
|
||||
|
||||
41
services/dbis-exchange/package-lock.json
generated
41
services/dbis-exchange/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
45
services/dbis-exchange/src/middleware/auth.ts
Normal file
45
services/dbis-exchange/src/middleware/auth.ts
Normal 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');
|
||||
}
|
||||
24
services/dbis-exchange/src/middleware/rate-limit.ts
Normal file
24
services/dbis-exchange/src/middleware/rate-limit.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
services/settlement-middleware/dist/middleware/auth.js
vendored
Normal file
20
services/settlement-middleware/dist/middleware/auth.js
vendored
Normal 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;
|
||||
}
|
||||
28
services/settlement-middleware/dist/middleware/rate-limit.js
vendored
Normal file
28
services/settlement-middleware/dist/middleware/rate-limit.js
vendored
Normal 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,
|
||||
}));
|
||||
20
services/settlement-middleware/dist/server.js
vendored
20
services/settlement-middleware/dist/server.js
vendored
@@ -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() {
|
||||
|
||||
28
services/settlement-middleware/package-lock.json
generated
28
services/settlement-middleware/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
18
services/settlement-middleware/src/middleware/auth.ts
Normal file
18
services/settlement-middleware/src/middleware/auth.ts
Normal 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;
|
||||
}
|
||||
24
services/settlement-middleware/src/middleware/rate-limit.ts
Normal file
24
services/settlement-middleware/src/middleware/rate-limit.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user