[TSM.ID].[11031972] PXE : Platform X Ecosystem I [118 Module -LIVE-]
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# XCU Omni-Relay Environment
|
||||
# Lokasi: xcu-omni-relay/.env.example
|
||||
# ==========================================
|
||||
|
||||
# Port Peladen
|
||||
PORT=4000
|
||||
@@ -0,0 +1,14 @@
|
||||
# [TSM.ID].[11031972] -- All Rights Reserved. Proprietary & Confidential.
|
||||
[package]
|
||||
name = "xcu-omni-relay"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["TSM.ID <tsm@tsm.id>"]
|
||||
description = "[TSM.ID].[11031972] Omni-directional relay routing bridge"
|
||||
|
||||
[lib]
|
||||
path = "rust_src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "xcu-omni-relay",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "xcu-omni-relay",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "xcu-omni-relay",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! [TSM.ID].[11031972] -- Platform X Ecosystem
|
||||
//! xcu-omni-relay -- Omni-directional relay routing bridge
|
||||
#![deny(warnings)]
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BridgeError {
|
||||
ConnectionFailed(String),
|
||||
ConfigError(String),
|
||||
OperationFailed(String),
|
||||
NotReady,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BridgeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ConnectionFailed(e) => write!(f, "Connection failed: {e}"),
|
||||
Self::ConfigError(e) => write!(f, "Config error: {e}"),
|
||||
Self::OperationFailed(e) => write!(f, "Op failed: {e}"),
|
||||
Self::NotReady => write!(f, "Not ready"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for BridgeError {}
|
||||
pub type Result<T> = std::result::Result<T, BridgeError>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BridgeConfig {
|
||||
pub name: String,
|
||||
pub endpoint: String,
|
||||
pub params: HashMap<String, String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl BridgeConfig {
|
||||
pub fn new(name: &str, endpoint: &str) -> Self {
|
||||
Self { name: name.to_string(), endpoint: endpoint.to_string(), params: HashMap::new(), enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BridgeState { Disconnected, Connecting, Connected, Error(String) }
|
||||
|
||||
pub struct Bridge {
|
||||
config: BridgeConfig,
|
||||
state: Arc<Mutex<BridgeState>>,
|
||||
message_count: Arc<Mutex<u64>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
pub fn new(config: BridgeConfig) -> Result<Self> {
|
||||
Ok(Self {
|
||||
config, state: Arc::new(Mutex::new(BridgeState::Disconnected)),
|
||||
message_count: Arc::new(Mutex::new(0)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn connect(&self) -> Result<()> {
|
||||
let mut s = self.state.lock().map_err(|e| BridgeError::OperationFailed(e.to_string()))?;
|
||||
*s = BridgeState::Connecting;
|
||||
*s = BridgeState::Connected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disconnect(&self) -> Result<()> {
|
||||
let mut s = self.state.lock().map_err(|e| BridgeError::OperationFailed(e.to_string()))?;
|
||||
*s = BridgeState::Disconnected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send(&self, _payload: &[u8]) -> Result<u64> {
|
||||
let s = self.state.lock().map_err(|e| BridgeError::OperationFailed(e.to_string()))?;
|
||||
if *s != BridgeState::Connected { return Err(BridgeError::NotReady); }
|
||||
drop(s);
|
||||
let mut c = self.message_count.lock().map_err(|e| BridgeError::OperationFailed(e.to_string()))?;
|
||||
*c += 1;
|
||||
Ok(*c)
|
||||
}
|
||||
|
||||
pub fn state(&self) -> Result<BridgeState> {
|
||||
Ok(self.state.lock().map_err(|e| BridgeError::OperationFailed(e.to_string()))?.clone())
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &BridgeConfig { &self.config }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_bridge() {
|
||||
let b = Bridge::new(BridgeConfig::new("xcu-omni-relay", "localhost:3000")).unwrap();
|
||||
assert_eq!(b.state().unwrap(), BridgeState::Disconnected);
|
||||
b.connect().unwrap();
|
||||
assert_eq!(b.state().unwrap(), BridgeState::Connected);
|
||||
b.send(b"hello").unwrap();
|
||||
b.disconnect().unwrap();
|
||||
assert_eq!(b.state().unwrap(), BridgeState::Disconnected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
// [TSM.ID].[11031972] -- All Rights Reserved. Proprietary & Confidential.
|
||||
// XCU Omni-Relay v2: API Gateway + WebSocket Sync Engine
|
||||
// Port 4000 — Sovereign XCU API Layer (connects to xcu_iam PostgreSQL)
|
||||
|
||||
require('dotenv').config();
|
||||
const { WebSocketServer } = require('ws');
|
||||
const http = require('http');
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
|
||||
// Connect to XCU's own PostgreSQL (xcu_iam), NOT jumpadb
|
||||
const pool = new Pool({
|
||||
host: process.env.XCU_DB_HOST || '127.0.0.1',
|
||||
port: process.env.XCU_DB_PORT || 5432,
|
||||
database: 'xcu_iam',
|
||||
user: process.env.XCU_DB_USER || 'postgres',
|
||||
password: process.env.XCU_DB_PASSWORD || '',
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[XCU API] PostgreSQL pool error:', err.message);
|
||||
});
|
||||
|
||||
// JUMPA DB pool for ONE-WAY PUSH bridge
|
||||
const jumpaPool = new Pool({
|
||||
host: process.env.JUMPA_DB_HOST || '127.0.0.1',
|
||||
port: process.env.JUMPA_DB_PORT || 5432,
|
||||
database: 'jumpadb',
|
||||
user: process.env.JUMPA_DB_USER || 'jumpa_admin',
|
||||
password: process.env.JUMPA_DB_PASSWORD || 'JumpaS3cur3!@#',
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// HTTP REST API
|
||||
// ═══════════════════════════════════════
|
||||
const parseBody = (req) => new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try { resolve(body ? JSON.parse(body) : {}); }
|
||||
catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
|
||||
const sendJson = (res, status, data) => {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
});
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
const path = url.pathname;
|
||||
|
||||
try {
|
||||
// ─── GET /api/v1/modules ───
|
||||
if (path === '/api/v1/modules' && req.method === 'GET') {
|
||||
const result = await pool.query(
|
||||
'SELECT id, name, group_id, group_name, sort_order, is_active FROM modules WHERE is_active = true ORDER BY sort_order'
|
||||
);
|
||||
|
||||
// Group by group_id for frontend consumption
|
||||
const registry = {};
|
||||
for (const row of result.rows) {
|
||||
if (!registry[row.group_id]) {
|
||||
registry[row.group_id] = {
|
||||
name: row.group_name,
|
||||
modules: []
|
||||
};
|
||||
}
|
||||
registry[row.group_id].modules.push({
|
||||
id: row.id,
|
||||
name: `${row.name} (Modul ${row.sort_order})`
|
||||
});
|
||||
}
|
||||
|
||||
return sendJson(res, 200, registry);
|
||||
}
|
||||
|
||||
// ─── POST /api/v1/modules ───
|
||||
if (path === '/api/v1/modules' && req.method === 'POST') {
|
||||
const body = await parseBody(req);
|
||||
const { id, name, group_id, group_name, sort_order } = body;
|
||||
if (!id || !name || !group_id || !group_name) {
|
||||
return sendJson(res, 400, { error: 'Missing required fields: id, name, group_id, group_name' });
|
||||
}
|
||||
await pool.query(
|
||||
'INSERT INTO modules (id, name, group_id, group_name, sort_order) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET name = $2, group_id = $3, group_name = $4, sort_order = $5',
|
||||
[id, name, group_id, group_name, sort_order || 0]
|
||||
);
|
||||
// Also insert into module_prices if not exists
|
||||
await pool.query(
|
||||
'INSERT INTO module_prices (module_id, price) VALUES ($1, 1500000) ON CONFLICT (module_id) DO NOTHING',
|
||||
[id]
|
||||
);
|
||||
broadcastModuleUpdate();
|
||||
return sendJson(res, 201, { status: 'MODULE_CREATED', id });
|
||||
}
|
||||
|
||||
// ─── DELETE /api/v1/modules/:id ───
|
||||
if (path.startsWith('/api/v1/modules/') && req.method === 'DELETE') {
|
||||
const moduleId = path.split('/').pop();
|
||||
await pool.query('UPDATE modules SET is_active = false WHERE id = $1', [moduleId]);
|
||||
broadcastModuleUpdate();
|
||||
return sendJson(res, 200, { status: 'MODULE_DEACTIVATED', id: moduleId });
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/tenants ───
|
||||
if (path === '/api/v1/tenants' && req.method === 'GET') {
|
||||
const result = await pool.query(
|
||||
'SELECT tenant_key, tenant_name, base_tier_id, custom_modules, custom_tier_name, status, created_at FROM tenant_allocations ORDER BY created_at DESC'
|
||||
);
|
||||
return sendJson(res, 200, result.rows);
|
||||
}
|
||||
|
||||
// ─── POST /api/v1/tenants ───
|
||||
if (path === '/api/v1/tenants' && req.method === 'POST') {
|
||||
const body = await parseBody(req);
|
||||
const { tenant_key, tenant_name, base_tier_id, custom_modules, custom_tier_name } = body;
|
||||
if (!tenant_key || !tenant_name) {
|
||||
return sendJson(res, 400, { error: 'Missing required fields: tenant_key, tenant_name' });
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO tenant_allocations (tenant_key, tenant_name, base_tier_id, custom_modules, custom_tier_name)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (tenant_key) DO UPDATE SET tenant_name = $2, base_tier_id = $3, custom_modules = $4, custom_tier_name = $5, updated_at = now()`,
|
||||
[tenant_key, tenant_name, base_tier_id || null, custom_modules || '{}', custom_tier_name || '']
|
||||
);
|
||||
return sendJson(res, 201, { status: 'TENANT_PROVISIONED', tenant_key });
|
||||
}
|
||||
|
||||
// ─── PUT /api/v1/tenants/:key ───
|
||||
if (path.startsWith('/api/v1/tenants/') && req.method === 'PUT') {
|
||||
const tenantKey = decodeURIComponent(path.split('/api/v1/tenants/')[1]);
|
||||
const body = await parseBody(req);
|
||||
const { tenant_name, base_tier_id, custom_modules, custom_tier_name, status } = body;
|
||||
await pool.query(
|
||||
`UPDATE tenant_allocations SET
|
||||
tenant_name = COALESCE($2, tenant_name),
|
||||
base_tier_id = $3,
|
||||
custom_modules = COALESCE($4, custom_modules),
|
||||
custom_tier_name = COALESCE($5, custom_tier_name),
|
||||
status = COALESCE($6, status),
|
||||
updated_at = now()
|
||||
WHERE tenant_key = $1`,
|
||||
[tenantKey, tenant_name, base_tier_id || null, custom_modules || '{}', custom_tier_name || '', status]
|
||||
);
|
||||
return sendJson(res, 200, { status: 'TENANT_UPDATED', tenant_key: tenantKey });
|
||||
}
|
||||
|
||||
// ─── DELETE /api/v1/tenants/:key ───
|
||||
if (path.startsWith('/api/v1/tenants/') && req.method === 'DELETE') {
|
||||
const tenantKey = decodeURIComponent(path.split('/api/v1/tenants/')[1]);
|
||||
await pool.query('DELETE FROM tenant_allocations WHERE tenant_key = $1', [tenantKey]);
|
||||
return sendJson(res, 200, { status: 'TENANT_REVOKED', tenant_key: tenantKey });
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/tiers ───
|
||||
if (path === '/api/v1/tiers' && req.method === 'GET') {
|
||||
const result = await pool.query('SELECT * FROM tiers WHERE is_active = true ORDER BY price');
|
||||
return sendJson(res, 200, result.rows);
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/module-prices ───
|
||||
if (path === '/api/v1/module-prices' && req.method === 'GET') {
|
||||
const result = await pool.query('SELECT module_id, price FROM module_prices ORDER BY module_id');
|
||||
const prices = {};
|
||||
for (const row of result.rows) {
|
||||
prices[row.module_id] = Number(row.price);
|
||||
}
|
||||
return sendJson(res, 200, prices);
|
||||
}
|
||||
|
||||
// ─── POST /api/v1/bridge/sync ── ONE-WAY PUSH to JUMPA ───
|
||||
if (path === '/api/v1/bridge/sync' && req.method === 'POST') {
|
||||
const body = await parseBody(req);
|
||||
const { tenant_key } = body;
|
||||
|
||||
if (!tenant_key) {
|
||||
return sendJson(res, 400, { error: 'Missing tenant_key' });
|
||||
}
|
||||
|
||||
// 1. Get tenant's allocated modules
|
||||
const tenantResult = await pool.query(
|
||||
'SELECT base_tier_id, custom_modules FROM tenant_allocations WHERE tenant_key = $1',
|
||||
[tenant_key]
|
||||
);
|
||||
|
||||
if (tenantResult.rows.length === 0) {
|
||||
return sendJson(res, 404, { error: 'Tenant not found' });
|
||||
}
|
||||
|
||||
const tenant = tenantResult.rows[0];
|
||||
let activeModules = tenant.custom_modules || [];
|
||||
|
||||
// If tenant uses a base tier, get modules from tier
|
||||
if (tenant.base_tier_id) {
|
||||
const tierResult = await pool.query('SELECT modules FROM tiers WHERE id = $1', [tenant.base_tier_id]);
|
||||
if (tierResult.rows.length > 0) {
|
||||
activeModules = tierResult.rows[0].modules;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get bridge mappings for these modules
|
||||
const bridgeResult = await pool.query(
|
||||
'SELECT xcu_module_id, jumpa_feature_key FROM module_feature_bridge WHERE xcu_module_id = ANY($1)',
|
||||
[activeModules]
|
||||
);
|
||||
|
||||
const activeFeatureKeys = bridgeResult.rows.map(r => r.jumpa_feature_key);
|
||||
|
||||
// 3. ONE-WAY PUSH: Update jumpadb.system_features
|
||||
// Activate features that are in the bridge mapping
|
||||
if (activeFeatureKeys.length > 0) {
|
||||
await jumpaPool.query(
|
||||
`UPDATE system_features SET default_state = 'GRANTED' WHERE key = ANY($1)`,
|
||||
[activeFeatureKeys]
|
||||
);
|
||||
}
|
||||
|
||||
// Get ALL bridge keys to know which to disable
|
||||
const allBridgeResult = await pool.query(
|
||||
'SELECT DISTINCT jumpa_feature_key FROM module_feature_bridge'
|
||||
);
|
||||
const allBridgeKeys = allBridgeResult.rows.map(r => r.jumpa_feature_key);
|
||||
const disabledKeys = allBridgeKeys.filter(k => !activeFeatureKeys.includes(k));
|
||||
|
||||
if (disabledKeys.length > 0) {
|
||||
await jumpaPool.query(
|
||||
`UPDATE system_features SET default_state = 'UPSELL' WHERE key = ANY($1)`,
|
||||
[disabledKeys]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[XCU BRIDGE] ONE-WAY PUSH to JUMPA: ${activeFeatureKeys.length} GRANTED, ${disabledKeys.length} UPSELL`);
|
||||
|
||||
return sendJson(res, 200, {
|
||||
status: 'BRIDGE_SYNC_COMPLETE',
|
||||
granted: activeFeatureKeys.length,
|
||||
disabled: disabledKeys.length,
|
||||
tenant_key
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/bridge/mappings ───
|
||||
if (path === '/api/v1/bridge/mappings' && req.method === 'GET') {
|
||||
const result = await pool.query(
|
||||
'SELECT xcu_module_id, jumpa_feature_key FROM module_feature_bridge ORDER BY xcu_module_id'
|
||||
);
|
||||
return sendJson(res, 200, result.rows);
|
||||
}
|
||||
|
||||
// ─── POST /api/v1/bridge/mappings ───
|
||||
if (path === '/api/v1/bridge/mappings' && req.method === 'POST') {
|
||||
const body = await parseBody(req);
|
||||
const { xcu_module_id, jumpa_feature_key } = body;
|
||||
if (!xcu_module_id || !jumpa_feature_key) {
|
||||
return sendJson(res, 400, { error: 'Missing xcu_module_id or jumpa_feature_key' });
|
||||
}
|
||||
await pool.query(
|
||||
'INSERT INTO module_feature_bridge (xcu_module_id, jumpa_feature_key) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[xcu_module_id, jumpa_feature_key]
|
||||
);
|
||||
return sendJson(res, 201, { status: 'MAPPING_CREATED', xcu_module_id, jumpa_feature_key });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// MONITORING API (Phase 1: Tenant Monitor)
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
// ─── GET /api/v1/monitor/health ── PM2 service status ───
|
||||
if (path === '/api/v1/monitor/health' && req.method === 'GET') {
|
||||
return new Promise((resolve) => {
|
||||
exec('pm2 jlist 2>/dev/null', (err, stdout) => {
|
||||
if (err) return resolve(sendJson(res, 200, { services: [], error: err.message }));
|
||||
try {
|
||||
const list = JSON.parse(stdout);
|
||||
const services = list.map(p => ({
|
||||
name: p.name,
|
||||
pid: p.pid,
|
||||
status: p.pm2_env.status,
|
||||
uptime: p.pm2_env.pm_uptime,
|
||||
restarts: p.pm2_env.restart_time,
|
||||
memory: p.monit ? p.monit.memory : 0,
|
||||
cpu: p.monit ? p.monit.cpu : 0,
|
||||
}));
|
||||
resolve(sendJson(res, 200, { services, ts: Date.now() }));
|
||||
} catch (e) {
|
||||
resolve(sendJson(res, 200, { services: [], error: e.message }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/monitor/traffic ── Nginx access log summary ───
|
||||
if (path === '/api/v1/monitor/traffic' && req.method === 'GET') {
|
||||
const search = url.searchParams.get('q') || '';
|
||||
const since = url.searchParams.get('since') || '';
|
||||
return new Promise((resolve) => {
|
||||
let cmd = 'tail -200 /var/log/nginx/access.log 2>/dev/null';
|
||||
exec(cmd, (err, stdout) => {
|
||||
if (err) return resolve(sendJson(res, 200, { entries: [], stats: {} }));
|
||||
let lines = stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
// Filter by search query
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
lines = lines.filter(l => l.toLowerCase().includes(q));
|
||||
}
|
||||
// Filter by datetime
|
||||
if (since) {
|
||||
lines = lines.filter(l => {
|
||||
const m = l.match(/\[([^\]]+)\]/);
|
||||
return m ? new Date(m[1].replace(':', ' ').replace(/\//g, '-')) >= new Date(since) : true;
|
||||
});
|
||||
}
|
||||
|
||||
// Stats
|
||||
const stats = { total: lines.length, '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
|
||||
lines.forEach(l => {
|
||||
const m = l.match(/"\s(\d{3})\s/);
|
||||
if (m) {
|
||||
const code = parseInt(m[1]);
|
||||
if (code < 300) stats['2xx']++;
|
||||
else if (code < 400) stats['3xx']++;
|
||||
else if (code < 500) stats['4xx']++;
|
||||
else stats['5xx']++;
|
||||
}
|
||||
});
|
||||
|
||||
// Return last 10
|
||||
const entries = lines.slice(-10).reverse();
|
||||
resolve(sendJson(res, 200, { entries, stats, ts: Date.now() }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/monitor/logs/:service ── PM2 logs ───
|
||||
if (path.startsWith('/api/v1/monitor/logs/') && req.method === 'GET') {
|
||||
const service = path.split('/api/v1/monitor/logs/')[1];
|
||||
const search = url.searchParams.get('q') || '';
|
||||
const lines = parseInt(url.searchParams.get('lines')) || 10;
|
||||
return new Promise((resolve) => {
|
||||
exec(`pm2 logs ${service} --nostream --lines ${Math.min(lines, 100)} 2>/dev/null`, (err, stdout) => {
|
||||
if (err) return resolve(sendJson(res, 200, { logs: [], error: err.message }));
|
||||
let logLines = stdout.trim().split('\n').filter(Boolean);
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
logLines = logLines.filter(l => l.toLowerCase().includes(q));
|
||||
}
|
||||
resolve(sendJson(res, 200, { logs: logLines.slice(-10), service, ts: Date.now() }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/monitor/intrusions ── Detect illegal access ───
|
||||
if (path === '/api/v1/monitor/intrusions' && req.method === 'GET') {
|
||||
return new Promise((resolve) => {
|
||||
const cmds = [
|
||||
// 1. Failed auth from Gatekeeper
|
||||
'grep -i "Auth Failed\\|Invalid Key\\|TOKEN_EXPIRED" /root/.pm2/logs/xcu-gatekeeper-error.log 2>/dev/null | tail -20',
|
||||
// 2. 401/403 from Nginx
|
||||
'grep -E "\\\" (401|403) " /var/log/nginx/access.log 2>/dev/null | tail -20',
|
||||
// 3. Suspicious patterns (scanning/probing)
|
||||
'grep -E "\\\" (404) .*(/admin|/wp-|/phpmyadmin|/.env|/config)" /var/log/nginx/access.log 2>/dev/null | tail -10'
|
||||
];
|
||||
|
||||
exec(cmds.join(' && echo "---SEPARATOR---" && '), { maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
||||
const sections = (stdout || '').split('---SEPARATOR---');
|
||||
const intrusions = [];
|
||||
|
||||
// Parse failed auth
|
||||
(sections[0] || '').trim().split('\n').filter(Boolean).forEach(line => {
|
||||
intrusions.push({ type: 'AUTH_FAILED', severity: 'HIGH', raw: line.substring(0, 200), ts: Date.now() });
|
||||
});
|
||||
|
||||
// Parse 401/403
|
||||
(sections[1] || '').trim().split('\n').filter(Boolean).forEach(line => {
|
||||
const ipMatch = line.match(/^(\d+\.\d+\.\d+\.\d+)/);
|
||||
const pathMatch = line.match(/"[A-Z]+ ([^ ]+)/);
|
||||
const isCrossTenant = pathMatch && (pathMatch[1].includes('/xcu-api') || pathMatch[1].includes('/ws/gatekeeper'));
|
||||
intrusions.push({
|
||||
type: isCrossTenant ? 'CROSS_TENANT_JUMP' : 'UNAUTHORIZED',
|
||||
severity: isCrossTenant ? 'CRITICAL' : 'MEDIUM',
|
||||
ip: ipMatch ? ipMatch[1] : 'unknown',
|
||||
target: pathMatch ? pathMatch[1] : 'unknown',
|
||||
raw: line.substring(0, 200),
|
||||
ts: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Parse probes
|
||||
(sections[2] || '').trim().split('\n').filter(Boolean).forEach(line => {
|
||||
const ipMatch = line.match(/^(\d+\.\d+\.\d+\.\d+)/);
|
||||
intrusions.push({
|
||||
type: 'API_PROBE',
|
||||
severity: 'LOW',
|
||||
ip: ipMatch ? ipMatch[1] : 'unknown',
|
||||
raw: line.substring(0, 200),
|
||||
ts: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Brute force detection: same IP > 3 failures
|
||||
const ipCounts = {};
|
||||
intrusions.filter(i => i.type === 'AUTH_FAILED' || i.type === 'UNAUTHORIZED').forEach(i => {
|
||||
if (i.ip) { ipCounts[i.ip] = (ipCounts[i.ip] || 0) + 1; }
|
||||
});
|
||||
const bruteForceIPs = Object.entries(ipCounts).filter(([_, c]) => c > 3).map(([ip]) => ip);
|
||||
|
||||
resolve(sendJson(res, 200, {
|
||||
intrusions: intrusions.slice(-30),
|
||||
bruteForceIPs,
|
||||
total: intrusions.length,
|
||||
ts: Date.now()
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── POST /api/v1/monitor/block-ip ── Manual IP block (default OFF) ───
|
||||
if (path === '/api/v1/monitor/block-ip' && req.method === 'POST') {
|
||||
const body = await parseBody(req);
|
||||
const { ip, action } = body; // action: 'block' or 'unblock'
|
||||
if (!ip) return sendJson(res, 400, { error: 'Missing IP' });
|
||||
return new Promise((resolve) => {
|
||||
// Always remove first to prevent duplicates, then add if blocking
|
||||
const cleanCmd = `while iptables -D INPUT -s ${ip} -j DROP 2>/dev/null; do :; done`;
|
||||
const cmd = action === 'unblock'
|
||||
? `${cleanCmd}; echo "UNBLOCKED ${ip}"`
|
||||
: `${cleanCmd}; iptables -A INPUT -s ${ip} -j DROP; echo "BLOCKED ${ip}"`;
|
||||
exec(cmd, (err, stdout) => {
|
||||
resolve(sendJson(res, 200, { status: stdout.trim(), ip, action }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/monitor/blocked ── List blocked IPs ───
|
||||
if (path === '/api/v1/monitor/blocked' && req.method === 'GET') {
|
||||
return new Promise((resolve) => {
|
||||
exec('iptables -L INPUT -n --line-numbers 2>/dev/null | grep DROP', (err, stdout) => {
|
||||
const blocked = (stdout || '').trim().split('\n').filter(Boolean).map(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
return { num: parts[0], ip: parts[4] || 'unknown', target: parts[1] || 'DROP' };
|
||||
}).filter(b => b.ip !== 'unknown' && b.ip !== '0.0.0.0/0');
|
||||
resolve(sendJson(res, 200, { blocked, total: blocked.length, ts: Date.now() }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Health check ───
|
||||
if (path === '/' || path === '/health') {
|
||||
return sendJson(res, 200, {
|
||||
service: 'XCU Omni-Relay API Gateway v2',
|
||||
status: 'ONLINE',
|
||||
sovereign: '[TSM.ID].[11031972]',
|
||||
port: PORT,
|
||||
database: 'xcu_iam (PostgreSQL)',
|
||||
bridge: 'ONE-WAY PUSH → JUMPA'
|
||||
});
|
||||
}
|
||||
|
||||
// 404
|
||||
sendJson(res, 404, { error: 'NOT_FOUND' });
|
||||
|
||||
} catch (err) {
|
||||
console.error('[XCU API] Error:', err.message);
|
||||
sendJson(res, 500, { error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// WebSocket Sync (for Command Center live updates)
|
||||
// ═══════════════════════════════════════
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
const broadcastModuleUpdate = async () => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT id, name, group_id, group_name, sort_order, is_active FROM modules WHERE is_active = true ORDER BY sort_order'
|
||||
);
|
||||
const registry = {};
|
||||
for (const row of result.rows) {
|
||||
if (!registry[row.group_id]) {
|
||||
registry[row.group_id] = { name: row.group_name, modules: [] };
|
||||
}
|
||||
registry[row.group_id].modules.push({
|
||||
id: row.id,
|
||||
name: `${row.name} (Modul ${row.sort_order})`
|
||||
});
|
||||
}
|
||||
const msg = JSON.stringify({ type: 'SYNC_MODULES', payload: registry });
|
||||
wss.clients.forEach(client => {
|
||||
if (client.readyState === 1) client.send(msg);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[XCU WS] Broadcast error:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
wss.on('connection', async (ws) => {
|
||||
console.log('[XCU API] Command Center Connected.');
|
||||
// Send current module registry immediately
|
||||
await broadcastModuleUpdate();
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
// Legacy support: handle UPDATE_PRICING from old Command Center
|
||||
if (data.type === 'UPDATE_PRICING') {
|
||||
console.log('[XCU API] Received legacy pricing update from Command Center.');
|
||||
// Persist tiers to DB if provided
|
||||
if (data.payload.tiers) {
|
||||
for (const tier of data.payload.tiers) {
|
||||
await pool.query(
|
||||
`UPDATE tiers SET name = $2, price = $3, description = $4, modules = $5 WHERE id = $1`,
|
||||
[tier.id, tier.name, tier.price, tier.description || '', tier.modules || []]
|
||||
);
|
||||
}
|
||||
}
|
||||
// Persist module prices if provided
|
||||
if (data.payload.modulePrices) {
|
||||
for (const [modId, price] of Object.entries(data.payload.modulePrices)) {
|
||||
await pool.query(
|
||||
'UPDATE module_prices SET price = $2, updated_at = now() WHERE module_id = $1',
|
||||
[modId, Number(price)]
|
||||
);
|
||||
}
|
||||
}
|
||||
// Broadcast to all connected clients
|
||||
const broadcastData = JSON.stringify({ type: 'SYNC_STATE', payload: data.payload });
|
||||
wss.clients.forEach(client => {
|
||||
if (client.readyState === 1) client.send(broadcastData);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[XCU API] WS message error:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[XCU API] Command Center Disconnected.');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[XCU API GATEWAY] Listening on http://localhost:${PORT}`);
|
||||
console.log(`[XCU API GATEWAY] Database: xcu_iam (PostgreSQL - INDEPENDENT)`);
|
||||
console.log(`[XCU API GATEWAY] Bridge: ONE-WAY PUSH → JUMPA`);
|
||||
console.log(`[XCU API GATEWAY] Sovereign: [TSM.ID].[11031972]`);
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
{
|
||||
"tiers": [
|
||||
{
|
||||
"id": "tier_perintis",
|
||||
"name": "PAKET PERINTIS",
|
||||
"price": 1500000,
|
||||
"description": "Akses infrastruktur dasar XCU Ultra untuk bisnis rintisan.",
|
||||
"modules": [
|
||||
"m1",
|
||||
"m2",
|
||||
"m3",
|
||||
"m4",
|
||||
"m5",
|
||||
"m17",
|
||||
"m20",
|
||||
"m21",
|
||||
"m23",
|
||||
"m29",
|
||||
"m30"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tier_korporat",
|
||||
"name": "PAKET KORPORAT",
|
||||
"price": 7500000,
|
||||
"description": "Kendali mutlak atas Sub-Core Alpha & Beta untuk skalabilitas Enterprise.",
|
||||
"modules": [
|
||||
"m1",
|
||||
"m2",
|
||||
"m3",
|
||||
"m4",
|
||||
"m5",
|
||||
"m6",
|
||||
"m7",
|
||||
"m8",
|
||||
"m9",
|
||||
"m10",
|
||||
"m11",
|
||||
"m12",
|
||||
"m13",
|
||||
"m14",
|
||||
"m15",
|
||||
"m16",
|
||||
"m17",
|
||||
"m18",
|
||||
"m19",
|
||||
"m20",
|
||||
"m21",
|
||||
"m22",
|
||||
"m23",
|
||||
"m24",
|
||||
"m25",
|
||||
"m26",
|
||||
"m27",
|
||||
"m28",
|
||||
"m29",
|
||||
"m30",
|
||||
"m31",
|
||||
"m32",
|
||||
"m33",
|
||||
"m34",
|
||||
"m35",
|
||||
"m36",
|
||||
"m37",
|
||||
"m38",
|
||||
"m39",
|
||||
"m40"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tier_kuantum",
|
||||
"name": "PAKET KUANTUM",
|
||||
"price": 15000000,
|
||||
"description": "Menembus batas ruang dan waktu. Akses Omega Core (AI / Quantum).",
|
||||
"modules": [
|
||||
"m1",
|
||||
"m2",
|
||||
"m3",
|
||||
"m4",
|
||||
"m5",
|
||||
"m6",
|
||||
"m7",
|
||||
"m8",
|
||||
"m9",
|
||||
"m10",
|
||||
"m11",
|
||||
"m12",
|
||||
"m13",
|
||||
"m14",
|
||||
"m15",
|
||||
"m16",
|
||||
"m17",
|
||||
"m18",
|
||||
"m19",
|
||||
"m20",
|
||||
"m21",
|
||||
"m22",
|
||||
"m23",
|
||||
"m24",
|
||||
"m25",
|
||||
"m26",
|
||||
"m27",
|
||||
"m28",
|
||||
"m29",
|
||||
"m30",
|
||||
"m31",
|
||||
"m32",
|
||||
"m33",
|
||||
"m34",
|
||||
"m35",
|
||||
"m36",
|
||||
"m37",
|
||||
"m38",
|
||||
"m39",
|
||||
"m40",
|
||||
"m41",
|
||||
"m42",
|
||||
"m43",
|
||||
"m44",
|
||||
"m45",
|
||||
"m46",
|
||||
"m47",
|
||||
"m48",
|
||||
"m49",
|
||||
"m50",
|
||||
"m51",
|
||||
"m52",
|
||||
"m53",
|
||||
"m54",
|
||||
"m55",
|
||||
"m56",
|
||||
"m57",
|
||||
"m58",
|
||||
"m59",
|
||||
"m60"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tier_jumpa_omni",
|
||||
"name": "JUMPA.ID OMNI-ENGINE",
|
||||
"price": 0,
|
||||
"description": "Mahakarya JUMPA.ID. Lisensi seumur hidup untuk seluruh 75 Modul.",
|
||||
"modules": [
|
||||
"m1",
|
||||
"m2",
|
||||
"m3",
|
||||
"m4",
|
||||
"m5",
|
||||
"m6",
|
||||
"m7",
|
||||
"m8",
|
||||
"m9",
|
||||
"m10",
|
||||
"m11",
|
||||
"m12",
|
||||
"m13",
|
||||
"m14",
|
||||
"m15",
|
||||
"m16",
|
||||
"m17",
|
||||
"m18",
|
||||
"m19",
|
||||
"m20",
|
||||
"m21",
|
||||
"m22",
|
||||
"m23",
|
||||
"m24",
|
||||
"m25",
|
||||
"m26",
|
||||
"m27",
|
||||
"m28",
|
||||
"m29",
|
||||
"m30",
|
||||
"m31",
|
||||
"m32",
|
||||
"m33",
|
||||
"m34",
|
||||
"m35",
|
||||
"m36",
|
||||
"m37",
|
||||
"m38",
|
||||
"m39",
|
||||
"m40",
|
||||
"m41",
|
||||
"m42",
|
||||
"m43",
|
||||
"m44",
|
||||
"m45",
|
||||
"m46",
|
||||
"m47",
|
||||
"m48",
|
||||
"m49",
|
||||
"m50",
|
||||
"m51",
|
||||
"m52",
|
||||
"m53",
|
||||
"m54",
|
||||
"m55",
|
||||
"m56",
|
||||
"m57",
|
||||
"m58",
|
||||
"m59",
|
||||
"m60",
|
||||
"m61",
|
||||
"m62",
|
||||
"m63",
|
||||
"m64",
|
||||
"m65",
|
||||
"m66",
|
||||
"m67",
|
||||
"m68",
|
||||
"m69",
|
||||
"m70",
|
||||
"m71",
|
||||
"m72",
|
||||
"m73",
|
||||
"m74",
|
||||
"m75"
|
||||
]
|
||||
}
|
||||
],
|
||||
"modulePrices": {
|
||||
"m1": 1500000,
|
||||
"m2": 1500000,
|
||||
"m3": 1500000,
|
||||
"m4": 1500000,
|
||||
"m5": 1500000,
|
||||
"m6": 1500000,
|
||||
"m7": 1500000,
|
||||
"m8": 1500000,
|
||||
"m9": 1500000,
|
||||
"m10": 1500000,
|
||||
"m11": 1500000,
|
||||
"m12": 1500000,
|
||||
"m13": 1500000,
|
||||
"m14": 1500000,
|
||||
"m15": 1500000,
|
||||
"m16": 1500000,
|
||||
"m17": 1500000,
|
||||
"m18": 1500000,
|
||||
"m19": 1500000,
|
||||
"m20": 1500000,
|
||||
"m21": 1500000,
|
||||
"m22": 1500000,
|
||||
"m23": 1500000,
|
||||
"m24": 1500000,
|
||||
"m25": 1500000,
|
||||
"m26": 1500000,
|
||||
"m27": 1500000,
|
||||
"m28": 1500000,
|
||||
"m29": 1500000,
|
||||
"m30": 1500000,
|
||||
"m31": 1500000,
|
||||
"m32": 1500000,
|
||||
"m33": 1500000,
|
||||
"m34": 1500000,
|
||||
"m35": 1500000,
|
||||
"m36": 1500000,
|
||||
"m37": 1500000,
|
||||
"m38": 1500000,
|
||||
"m39": 1500000,
|
||||
"m40": 1500000,
|
||||
"m41": 1500000,
|
||||
"m42": 1500000,
|
||||
"m43": 1500000,
|
||||
"m44": 1500000,
|
||||
"m45": 1500000,
|
||||
"m46": 1500000,
|
||||
"m47": 1500000,
|
||||
"m48": 1500000,
|
||||
"m49": 1500000,
|
||||
"m50": 1500000,
|
||||
"m51": 1500000,
|
||||
"m52": 1500000,
|
||||
"m53": 1500000,
|
||||
"m54": 1500000,
|
||||
"m55": 1500000,
|
||||
"m56": 1500000,
|
||||
"m57": 1500000,
|
||||
"m58": 1500000,
|
||||
"m59": 1500000,
|
||||
"m60": 1500000,
|
||||
"m61": 1500000,
|
||||
"m62": 1500000,
|
||||
"m63": 1500000,
|
||||
"m64": 1500000,
|
||||
"m65": 1500000,
|
||||
"m66": 1500000,
|
||||
"m67": 1500000,
|
||||
"m68": 1500000,
|
||||
"m69": 1500000,
|
||||
"m70": 1500000,
|
||||
"m71": 1500000,
|
||||
"m72": 1500000,
|
||||
"m73": 1500000,
|
||||
"m74": 1500000,
|
||||
"m75": 1500000
|
||||
},
|
||||
"taxRate": 11,
|
||||
"activeGateways": {
|
||||
"stripe": true,
|
||||
"xendit": true,
|
||||
"crypto": true,
|
||||
"unionpay": true,
|
||||
"bank_transfer": true,
|
||||
"cash_manual": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user