LandveX Gateway v0.1.0 + Operations API + Communications Module — ALL RUNNING
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@landvex/communications",
|
||||
"version": "0.1.0",
|
||||
"description": "LandveX Communications Module — Email, SMS, Push notifications",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.0.0",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/cors": "^2.8.13",
|
||||
"typescript": "^5.1.0",
|
||||
"ts-node": "^10.9.0",
|
||||
"jest": "^29.5.0",
|
||||
"@types/jest": "^29.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* LandveX Communications Module
|
||||
*
|
||||
* Email, SMS, Push notifications
|
||||
* Port: 3010
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { handleRequest } from './router.mjs';
|
||||
|
||||
const PORT = process.env.PORT || 3010;
|
||||
|
||||
const server = http.createServer(handleRequest);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`📧 LandveX Communications running on port ${PORT}`);
|
||||
console.log(`📨 Endpoints:`);
|
||||
console.log(` GET /health → Health check`);
|
||||
console.log(` GET /api/v1/communications/threads → List threads`);
|
||||
console.log(` POST /api/v1/communications/threads → Create thread`);
|
||||
console.log(` POST /api/v1/communications/send → Send message`);
|
||||
console.log(` GET /api/v1/communications/templates → List templates`);
|
||||
console.log(` POST /api/v1/communications/templates → Create template`);
|
||||
console.log(` POST /webhooks/mailgun → Mailgun webhook`);
|
||||
console.log(` POST /webhooks/twilio → Twilio webhook`);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* LandveX Communications Module
|
||||
*
|
||||
* Email, SMS, Push notifications
|
||||
* Port: 3010
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { handleRequest } from './router.js';
|
||||
|
||||
const PORT = process.env.PORT || 3010;
|
||||
|
||||
const server = http.createServer(handleRequest);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`📧 LandveX Communications running on port ${PORT}`);
|
||||
console.log(`📨 Endpoints:`);
|
||||
console.log(` GET /health → Health check`);
|
||||
console.log(` GET /api/v1/communications/threads → List threads`);
|
||||
console.log(` POST /api/v1/communications/threads → Create thread`);
|
||||
console.log(` POST /api/v1/communications/send → Send message`);
|
||||
console.log(` GET /api/v1/communications/templates → List templates`);
|
||||
console.log(` POST /api/v1/communications/templates → Create template`);
|
||||
console.log(` POST /webhooks/mailgun → Mailgun webhook`);
|
||||
console.log(` POST /webhooks/twilio → Twilio webhook`);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* LandveX Communications — Router
|
||||
* Zero-dependency HTTP router
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
|
||||
// In-memory store
|
||||
const threads = {};
|
||||
const templates = {};
|
||||
const messages = {};
|
||||
|
||||
export function handleRequest(req, res) {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
const path = url.pathname;
|
||||
|
||||
// Health check
|
||||
if (path === '/health') {
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
version: '0.1.0-communications',
|
||||
service: 'communications',
|
||||
channels: ['email', 'sms', 'push'],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Threads
|
||||
if (path === '/api/v1/communications/threads' && req.method === 'GET') {
|
||||
handleListThreads(req, res, url);
|
||||
} else if (path === '/api/v1/communications/threads' && req.method === 'POST') {
|
||||
handleCreateThread(req, res);
|
||||
} else if (path.startsWith('/api/v1/communications/threads/') && req.method === 'GET') {
|
||||
handleGetThread(req, res, path);
|
||||
}
|
||||
// Messages
|
||||
else if (path === '/api/v1/communications/send' && req.method === 'POST') {
|
||||
handleSendMessage(req, res);
|
||||
}
|
||||
// Templates
|
||||
else if (path === '/api/v1/communications/templates' && req.method === 'GET') {
|
||||
handleListTemplates(req, res);
|
||||
} else if (path === '/api/v1/communications/templates' && req.method === 'POST') {
|
||||
handleCreateTemplate(req, res);
|
||||
}
|
||||
// Webhooks
|
||||
else if (path === '/webhooks/mailgun' && req.method === 'POST') {
|
||||
handleMailgunWebhook(req, res);
|
||||
} else if (path === '/webhooks/twilio' && req.method === 'POST') {
|
||||
handleTwilioWebhook(req, res);
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Not Found', path }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleListThreads(req, res, url) {
|
||||
const customerId = url.searchParams.get('customer') || 'all';
|
||||
const type = url.searchParams.get('type');
|
||||
|
||||
let result = Object.values(threads);
|
||||
if (customerId !== 'all') {
|
||||
result = result.filter((t) => t.customerId === customerId);
|
||||
}
|
||||
if (type) {
|
||||
result = result.filter((t) => t.type === type);
|
||||
}
|
||||
|
||||
res.end(JSON.stringify({
|
||||
threads: result,
|
||||
count: result.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCreateThread(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const thread = {
|
||||
id: `thread-${Date.now()}`,
|
||||
...data,
|
||||
messages: [],
|
||||
status: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
threads[thread.id] = thread;
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify(thread));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetThread(req, res, path) {
|
||||
const id = path.split('/').pop();
|
||||
const thread = threads[id || ''];
|
||||
|
||||
if (!thread) {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Thread not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.end(JSON.stringify(thread));
|
||||
}
|
||||
|
||||
function handleSendMessage(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
|
||||
// Simulate sending
|
||||
const message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
...data,
|
||||
status: 'sent',
|
||||
sentAt: new Date().toISOString(),
|
||||
providerMessageId: `mock-${Date.now()}`,
|
||||
};
|
||||
|
||||
// Add to thread if specified
|
||||
if (data.threadId && threads[data.threadId]) {
|
||||
threads[data.threadId].messages.push(message);
|
||||
threads[data.threadId].updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message,
|
||||
note: 'Mock send — connect Mailgun/Twilio for real sending',
|
||||
}));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleListTemplates(req, res) {
|
||||
const result = Object.values(templates);
|
||||
res.end(JSON.stringify({
|
||||
templates: result,
|
||||
count: result.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCreateTemplate(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const template = {
|
||||
id: `template-${Date.now()}`,
|
||||
...data,
|
||||
usageCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
templates[template.id] = template;
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify(template));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleMailgunWebhook(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
console.log('[Mailgun Webhook]', body);
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({ received: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function handleTwilioWebhook(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
console.log('[Twilio Webhook]', body);
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({ received: true }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* LandveX Communications — Router
|
||||
* Zero-dependency HTTP router
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
|
||||
// In-memory store
|
||||
const threads: Record<string, any> = {};
|
||||
const templates: Record<string, any> = {};
|
||||
const messages: Record<string, any[]> = {};
|
||||
|
||||
export function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
const path = url.pathname;
|
||||
|
||||
// Health check
|
||||
if (path === '/health') {
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
version: '0.1.0-communications',
|
||||
service: 'communications',
|
||||
channels: ['email', 'sms', 'push'],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Threads
|
||||
if (path === '/api/v1/communications/threads' && req.method === 'GET') {
|
||||
handleListThreads(req, res, url);
|
||||
} else if (path === '/api/v1/communications/threads' && req.method === 'POST') {
|
||||
handleCreateThread(req, res);
|
||||
} else if (path.startsWith('/api/v1/communications/threads/') && req.method === 'GET') {
|
||||
handleGetThread(req, res, path);
|
||||
}
|
||||
// Messages
|
||||
else if (path === '/api/v1/communications/send' && req.method === 'POST') {
|
||||
handleSendMessage(req, res);
|
||||
}
|
||||
// Templates
|
||||
else if (path === '/api/v1/communications/templates' && req.method === 'GET') {
|
||||
handleListTemplates(req, res);
|
||||
} else if (path === '/api/v1/communications/templates' && req.method === 'POST') {
|
||||
handleCreateTemplate(req, res);
|
||||
}
|
||||
// Webhooks
|
||||
else if (path === '/webhooks/mailgun' && req.method === 'POST') {
|
||||
handleMailgunWebhook(req, res);
|
||||
} else if (path === '/webhooks/twilio' && req.method === 'POST') {
|
||||
handleTwilioWebhook(req, res);
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Not Found', path }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleListThreads(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {
|
||||
const customerId = url.searchParams.get('customer') || 'all';
|
||||
const type = url.searchParams.get('type');
|
||||
|
||||
let result = Object.values(threads);
|
||||
if (customerId !== 'all') {
|
||||
result = result.filter((t: any) => t.customerId === customerId);
|
||||
}
|
||||
if (type) {
|
||||
result = result.filter((t: any) => t.type === type);
|
||||
}
|
||||
|
||||
res.end(JSON.stringify({
|
||||
threads: result,
|
||||
count: result.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCreateThread(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const thread = {
|
||||
id: `thread-${Date.now()}`,
|
||||
...data,
|
||||
messages: [],
|
||||
status: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
threads[thread.id] = thread;
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify(thread));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetThread(req: http.IncomingMessage, res: http.ServerResponse, path: string): void {
|
||||
const id = path.split('/').pop();
|
||||
const thread = threads[id || ''];
|
||||
|
||||
if (!thread) {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Thread not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.end(JSON.stringify(thread));
|
||||
}
|
||||
|
||||
function handleSendMessage(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
|
||||
// Simulate sending
|
||||
const message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
...data,
|
||||
status: 'sent',
|
||||
sentAt: new Date().toISOString(),
|
||||
providerMessageId: `mock-${Date.now()}`,
|
||||
};
|
||||
|
||||
// Add to thread if specified
|
||||
if (data.threadId && threads[data.threadId]) {
|
||||
threads[data.threadId].messages.push(message);
|
||||
threads[data.threadId].updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message,
|
||||
note: 'Mock send — connect Mailgun/Twilio for real sending',
|
||||
}));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleListTemplates(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const result = Object.values(templates);
|
||||
res.end(JSON.stringify({
|
||||
templates: result,
|
||||
count: result.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCreateTemplate(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const template = {
|
||||
id: `template-${Date.now()}`,
|
||||
...data,
|
||||
usageCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
templates[template.id] = template;
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify(template));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleMailgunWebhook(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
console.log('[Mailgun Webhook]', body);
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({ received: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function handleTwilioWebhook(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
console.log('[Twilio Webhook]', body);
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({ received: true }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* LandveX Gateway — Authentication & Authorization
|
||||
* JWT-based auth with role checking
|
||||
*/
|
||||
|
||||
// Simple JWT verification (no external deps)
|
||||
export function verifyToken(token) {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role || 'customer',
|
||||
orgId: payload.orgId,
|
||||
capabilities: payload.capabilities || [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractToken(req) {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth) return null;
|
||||
|
||||
const parts = auth.split(' ');
|
||||
if (parts.length === 2 && parts[0] === 'Bearer') {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function requireAuth(req, res) {
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
res.statusCode = 401;
|
||||
res.end(JSON.stringify({ error: 'Unauthorized', message: 'No token provided' }));
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = verifyToken(token);
|
||||
if (!user) {
|
||||
res.statusCode = 401;
|
||||
res.end(JSON.stringify({ error: 'Unauthorized', message: 'Invalid token' }));
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireRole(user, roles, res) {
|
||||
if (!roles.includes(user.role)) {
|
||||
res.statusCode = 403;
|
||||
res.end(JSON.stringify({ error: 'Forbidden', message: 'Insufficient permissions' }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API Key authentication
|
||||
export function verifyApiKey(key) {
|
||||
if (key.startsWith('lvx_prod_')) {
|
||||
return { valid: true, tier: 'production', rateLimit: 10000 };
|
||||
}
|
||||
if (key.startsWith('lvx_test_')) {
|
||||
return { valid: true, tier: 'test', rateLimit: 100 };
|
||||
}
|
||||
return { valid: false };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* LandveX Gateway — Authentication & Authorization
|
||||
* JWT-based auth with role checking
|
||||
*/
|
||||
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { GATEWAY_CONFIG } from './config';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: 'superadmin' | 'admin' | 'operator' | 'customer';
|
||||
orgId?: string;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
// Simple JWT verification (no external deps)
|
||||
export function verifyToken(token: string): User | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
|
||||
// Check expiration
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role || 'customer',
|
||||
orgId: payload.orgId,
|
||||
capabilities: payload.capabilities || [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractToken(req: IncomingMessage): string | null {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth) return null;
|
||||
|
||||
const parts = auth.split(' ');
|
||||
if (parts.length === 2 && parts[0] === 'Bearer') {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function requireAuth(req: IncomingMessage, res: ServerResponse): User | null {
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
res.statusCode = 401;
|
||||
res.end(JSON.stringify({ error: 'Unauthorized', message: 'No token provided' }));
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = verifyToken(token);
|
||||
if (!user) {
|
||||
res.statusCode = 401;
|
||||
res.end(JSON.stringify({ error: 'Unauthorized', message: 'Invalid token' }));
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireRole(user: User, roles: string[], res: ServerResponse): boolean {
|
||||
if (!roles.includes(user.role)) {
|
||||
res.statusCode = 403;
|
||||
res.end(JSON.stringify({ error: 'Forbidden', message: 'Insufficient permissions' }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API Key authentication
|
||||
export function verifyApiKey(key: string): { valid: boolean; tier?: string; rateLimit?: number } {
|
||||
// TODO: Implement proper API key validation against database
|
||||
// For now, simple prefix check
|
||||
if (key.startsWith('lvx_prod_')) {
|
||||
return { valid: true, tier: 'production', rateLimit: 10000 };
|
||||
}
|
||||
if (key.startsWith('lvx_test_')) {
|
||||
return { valid: true, tier: 'test', rateLimit: 100 };
|
||||
}
|
||||
return { valid: false };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* LandveX Gateway Configuration
|
||||
* Central config for all service routing
|
||||
*/
|
||||
|
||||
export const SERVICES = {
|
||||
intelligence: {
|
||||
name: 'Intelligence Lab',
|
||||
host: process.env.INTELLIGENCE_HOST || 'localhost',
|
||||
port: parseInt(process.env.INTELLIGENCE_PORT || '3002'),
|
||||
path: '/api/v1',
|
||||
healthPath: '/health',
|
||||
},
|
||||
operations: {
|
||||
name: 'Operations API',
|
||||
host: process.env.OPERATIONS_HOST || 'localhost',
|
||||
port: parseInt(process.env.OPERATIONS_PORT || '3005'),
|
||||
path: '/api/v1',
|
||||
healthPath: '/health',
|
||||
},
|
||||
communications: {
|
||||
name: 'Communications',
|
||||
host: process.env.COMMUNICATIONS_HOST || 'localhost',
|
||||
port: parseInt(process.env.COMMUNICATIONS_PORT || '3010'),
|
||||
path: '/api/v1',
|
||||
healthPath: '/health',
|
||||
},
|
||||
apollo: {
|
||||
name: 'Apollo CRM',
|
||||
host: process.env.APOLLO_HOST || 'localhost',
|
||||
port: parseInt(process.env.APOLLO_PORT || '3001'),
|
||||
path: '/api/apollo',
|
||||
healthPath: '/status',
|
||||
},
|
||||
ledger: {
|
||||
name: 'AAMOS Ledger',
|
||||
host: process.env.LEDGER_HOST || 'localhost',
|
||||
port: parseInt(process.env.LEDGER_PORT || '3250'),
|
||||
path: '',
|
||||
healthPath: '/health',
|
||||
},
|
||||
incidents: {
|
||||
name: 'AAMOS Incidents',
|
||||
host: process.env.INCIDENTS_HOST || 'localhost',
|
||||
port: parseInt(process.env.INCIDENTS_PORT || '3303'),
|
||||
path: '',
|
||||
healthPath: '/health',
|
||||
},
|
||||
};
|
||||
|
||||
export const GATEWAY_CONFIG = {
|
||||
port: parseInt(process.env.GATEWAY_PORT || '3004'),
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
jwtSecret: process.env.JWT_SECRET || 'landvex-dev-secret-change-in-production',
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
rateLimitWindow: 15 * 60 * 1000,
|
||||
rateLimitMax: 100,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* LandveX Gateway Configuration
|
||||
* Central config for all service routing
|
||||
*/
|
||||
|
||||
export interface ServiceConfig {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
healthPath: string;
|
||||
}
|
||||
|
||||
export const SERVICES: Record<string, ServiceConfig> = {
|
||||
intelligence: {
|
||||
name: 'Intelligence Lab',
|
||||
host: process.env.INTELLIGENCE_HOST || 'localhost',
|
||||
port: parseInt(process.env.INTELLIGENCE_PORT || '3002'),
|
||||
path: '/api/v1',
|
||||
healthPath: '/health',
|
||||
},
|
||||
operations: {
|
||||
name: 'Operations API',
|
||||
host: process.env.OPERATIONS_HOST || 'localhost',
|
||||
port: parseInt(process.env.OPERATIONS_PORT || '3005'),
|
||||
path: '/api/v1',
|
||||
healthPath: '/health',
|
||||
},
|
||||
communications: {
|
||||
name: 'Communications',
|
||||
host: process.env.COMMUNICATIONS_HOST || 'localhost',
|
||||
port: parseInt(process.env.COMMUNICATIONS_PORT || '3010'),
|
||||
path: '/api/v1',
|
||||
healthPath: '/health',
|
||||
},
|
||||
apollo: {
|
||||
name: 'Apollo CRM',
|
||||
host: process.env.APOLLO_HOST || 'localhost',
|
||||
port: parseInt(process.env.APOLLO_PORT || '3001'),
|
||||
path: '/api/apollo',
|
||||
healthPath: '/status',
|
||||
},
|
||||
ledger: {
|
||||
name: 'AAMOS Ledger',
|
||||
host: process.env.LEDGER_HOST || 'localhost',
|
||||
port: parseInt(process.env.LEDGER_PORT || '3250'),
|
||||
path: '',
|
||||
healthPath: '/health',
|
||||
},
|
||||
incidents: {
|
||||
name: 'AAMOS Incidents',
|
||||
host: process.env.INCIDENTS_HOST || 'localhost',
|
||||
port: parseInt(process.env.INCIDENTS_PORT || '3303'),
|
||||
path: '',
|
||||
healthPath: '/health',
|
||||
},
|
||||
};
|
||||
|
||||
export const GATEWAY_CONFIG = {
|
||||
port: parseInt(process.env.GATEWAY_PORT || '3004'),
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
jwtSecret: process.env.JWT_SECRET || 'landvex-dev-secret-change-in-production',
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
rateLimitWindow: 15 * 60 * 1000, // 15 minutes
|
||||
rateLimitMax: 100,
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* LandveX Master Gateway
|
||||
*
|
||||
* Unifies all backend services:
|
||||
* - Intelligence Lab (port 3002)
|
||||
* - Operations API (port 3005)
|
||||
* - Communications (port 3010)
|
||||
* - Apollo CRM (port 3001)
|
||||
* - AAMOS Ledger (port 3250)
|
||||
* - AAMOS Incidents (port 3303)
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { SERVICES } from './config.mjs';
|
||||
import { verifyApiKey } from './auth.mjs';
|
||||
import { proxyRequest, checkServiceHealth } from './proxy.mjs';
|
||||
|
||||
const PORT = process.env.GATEWAY_PORT || 3004;
|
||||
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
|
||||
const RATE_LIMIT_MAX = 100;
|
||||
|
||||
// Service health status
|
||||
const serviceHealth = {};
|
||||
|
||||
// Rate limiting (simple in-memory)
|
||||
const rateLimits = {};
|
||||
|
||||
function checkRateLimit(identifier, maxRequests) {
|
||||
const now = Date.now();
|
||||
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW) * RATE_LIMIT_WINDOW;
|
||||
|
||||
if (!rateLimits[identifier] || rateLimits[identifier].resetAt < windowStart) {
|
||||
rateLimits[identifier] = { count: 0, resetAt: windowStart + RATE_LIMIT_WINDOW };
|
||||
}
|
||||
|
||||
rateLimits[identifier].count++;
|
||||
return rateLimits[identifier].count <= maxRequests;
|
||||
}
|
||||
|
||||
// CORS headers
|
||||
function setCorsHeaders(res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
||||
}
|
||||
|
||||
// Main server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
setCorsHeaders(res);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url || '/';
|
||||
|
||||
// Health check
|
||||
if (url === '/health') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
version: '0.1.0-gateway',
|
||||
services: Object.entries(serviceHealth).map(([name, health]) => ({
|
||||
name,
|
||||
healthy: health.healthy,
|
||||
latency: health.latency,
|
||||
lastCheck: health.lastCheck,
|
||||
error: health.error,
|
||||
})),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// API Key auth for external API access
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey) {
|
||||
const keyInfo = verifyApiKey(apiKey);
|
||||
if (!keyInfo.valid) {
|
||||
res.statusCode = 401;
|
||||
res.end(JSON.stringify({ error: 'Invalid API key' }));
|
||||
return;
|
||||
}
|
||||
if (!checkRateLimit(apiKey, keyInfo.rateLimit || 100)) {
|
||||
res.statusCode = 429;
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Route to services — ORDER MATTERS (specific first)
|
||||
if (url.startsWith('/api/v1/government') || url.startsWith('/api/v1/customers')) {
|
||||
proxyRequest(req, res, SERVICES.operations);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/v1/communications')) {
|
||||
proxyRequest(req, res, SERVICES.communications);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/v1/missions') || url.startsWith('/api/v1/artifacts')) {
|
||||
proxyRequest(req, res, SERVICES.intelligence);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/apollo')) {
|
||||
proxyRequest(req, res, SERVICES.apollo);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/ledger')) {
|
||||
proxyRequest(req, res, SERVICES.ledger);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/incidents')) {
|
||||
proxyRequest(req, res, SERVICES.incidents);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === '/webhooks/stripe') {
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({ received: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Not Found', path: url }));
|
||||
});
|
||||
|
||||
// Health check loop
|
||||
async function updateHealth() {
|
||||
for (const [name, service] of Object.entries(SERVICES)) {
|
||||
const health = await checkServiceHealth(service);
|
||||
serviceHealth[name] = {
|
||||
...health,
|
||||
lastCheck: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initial health check
|
||||
updateHealth();
|
||||
setInterval(updateHealth, 30000);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 LandveX Gateway running on port ${PORT}`);
|
||||
console.log(`📡 Services:`);
|
||||
Object.entries(SERVICES).forEach(([key, service]) => {
|
||||
console.log(` - ${service.name}: http://${service.host}:${service.port}`);
|
||||
});
|
||||
});
|
||||
+122
-81
@@ -1,95 +1,136 @@
|
||||
/**
|
||||
* LandveX Master Gateway
|
||||
*
|
||||
*
|
||||
* Unifies all backend services:
|
||||
* - Intelligence Lab (port 3002)
|
||||
* - Apollo CRM (/api/apollo)
|
||||
* - Stripe billing
|
||||
* - aamos-ledger (port 3250)
|
||||
* - aamos-incidents (port 3303)
|
||||
* - Auth & API keys
|
||||
* - Operations API (port 3005)
|
||||
* - Communications (port 3010)
|
||||
* - Apollo CRM (port 3001)
|
||||
* - AAMOS Ledger (port 3250)
|
||||
* - AAMOS Incidents (port 3303)
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import dotenv from 'dotenv';
|
||||
import http from 'http';
|
||||
import { GATEWAY_CONFIG, SERVICES } from './config';
|
||||
import { requireAuth, requireRole, verifyApiKey } from './auth';
|
||||
import { proxyRequest, checkServiceHealth } from './proxy';
|
||||
|
||||
dotenv.config();
|
||||
// Service health status
|
||||
const serviceHealth: Record<string, { healthy: boolean; latency: number; lastCheck: number; error?: string }> = {};
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3004;
|
||||
// Rate limiting (simple in-memory)
|
||||
const rateLimits: Record<string, { count: number; resetAt: number }> = {};
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
function checkRateLimit(identifier: string, maxRequests: number): boolean {
|
||||
const now = Date.now();
|
||||
const windowStart = Math.floor(now / GATEWAY_CONFIG.rateLimitWindow) * GATEWAY_CONFIG.rateLimitWindow;
|
||||
|
||||
if (!rateLimits[identifier] || rateLimits[identifier].resetAt < windowStart) {
|
||||
rateLimits[identifier] = { count: 0, resetAt: windowStart + GATEWAY_CONFIG.rateLimitWindow };
|
||||
}
|
||||
|
||||
rateLimits[identifier].count++;
|
||||
return rateLimits[identifier].count <= maxRequests;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per windowMs
|
||||
});
|
||||
app.use(limiter);
|
||||
// CORS headers
|
||||
function setCorsHeaders(res: http.ServerResponse): void {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
||||
}
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
version: '0.1.0-master',
|
||||
services: {
|
||||
intelligence: 'http://localhost:3002',
|
||||
apollo: '/api/apollo',
|
||||
ledger: 'http://localhost:3250',
|
||||
incidents: 'http://localhost:3303',
|
||||
// Main server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
setCorsHeaders(res);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url || '/';
|
||||
|
||||
// Health check
|
||||
if (url === '/health') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
version: '0.1.0-gateway',
|
||||
environment: GATEWAY_CONFIG.env,
|
||||
services: Object.entries(serviceHealth).map(([name, health]) => ({
|
||||
name,
|
||||
healthy: health.healthy,
|
||||
latency: health.latency,
|
||||
lastCheck: health.lastCheck,
|
||||
error: health.error,
|
||||
})),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// API Key auth for external API access
|
||||
const apiKey = req.headers['x-api-key'] as string;
|
||||
if (apiKey) {
|
||||
const keyInfo = verifyApiKey(apiKey);
|
||||
if (!keyInfo.valid) {
|
||||
res.statusCode = 401;
|
||||
res.end(JSON.stringify({ error: 'Invalid API key' }));
|
||||
return;
|
||||
}
|
||||
if (!checkRateLimit(apiKey, keyInfo.rateLimit || 100)) {
|
||||
res.statusCode = 429;
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Route to services
|
||||
if (url.startsWith('/api/v1/government') || url.startsWith('/api/v1/customers')) {
|
||||
proxyRequest(req, res, SERVICES.operations);
|
||||
return;
|
||||
} else if (url.startsWith('/api/v1/communications')) {
|
||||
proxyRequest(req, res, SERVICES.communications);
|
||||
return;
|
||||
} else if (url.startsWith('/api/v1/missions') || url.startsWith('/api/v1/artifacts')) {
|
||||
proxyRequest(req, res, SERVICES.intelligence);
|
||||
return;
|
||||
} else if (url.startsWith('/api/apollo')) {
|
||||
proxyRequest(req, res, SERVICES.apollo);
|
||||
} else if (url.startsWith('/api/ledger')) {
|
||||
proxyRequest(req, res, SERVICES.ledger);
|
||||
} else if (url.startsWith('/api/incidents')) {
|
||||
proxyRequest(req, res, SERVICES.incidents);
|
||||
} else if (url === '/webhooks/stripe') {
|
||||
// Stripe webhook — handle directly or proxy to billing
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({ received: true }));
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Not Found', path: url }));
|
||||
}
|
||||
});
|
||||
|
||||
// Health check loop
|
||||
async function updateHealth() {
|
||||
for (const [name, service] of Object.entries(SERVICES)) {
|
||||
const health = await checkServiceHealth(service);
|
||||
serviceHealth[name] = {
|
||||
...health,
|
||||
lastCheck: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initial health check
|
||||
updateHealth();
|
||||
setInterval(updateHealth, 30000); // Every 30 seconds
|
||||
|
||||
server.listen(GATEWAY_CONFIG.port, () => {
|
||||
console.log(`🚀 LandveX Gateway running on port ${GATEWAY_CONFIG.port}`);
|
||||
console.log(`📡 Services:`);
|
||||
Object.entries(SERVICES).forEach(([key, service]) => {
|
||||
console.log(` - ${service.name}: http://${service.host}:${service.port}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Proxy to Intelligence Lab
|
||||
app.use('/api/v1', createProxyMiddleware({
|
||||
target: 'http://localhost:3002',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api/v1': '/api/v1' },
|
||||
}));
|
||||
|
||||
// Proxy to Apollo CRM
|
||||
app.use('/api/apollo', createProxyMiddleware({
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api/apollo': '/api/apollo' },
|
||||
}));
|
||||
|
||||
// Proxy to Ledger
|
||||
app.use('/api/ledger', createProxyMiddleware({
|
||||
target: 'http://localhost:3250',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api/ledger': '' },
|
||||
}));
|
||||
|
||||
// Proxy to Incidents
|
||||
app.use('/api/incidents', createProxyMiddleware({
|
||||
target: 'http://localhost:3303',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api/incidents': '' },
|
||||
}));
|
||||
|
||||
// Stripe webhook
|
||||
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
|
||||
// TODO: Implement Stripe webhook handling
|
||||
res.json({ received: true });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 LandveX Master Gateway running on port ${PORT}`);
|
||||
console.log(`📡 Proxying to:`);
|
||||
console.log(` - Intelligence Lab: http://localhost:3002`);
|
||||
console.log(` - Apollo CRM: http://localhost:3001`);
|
||||
console.log(` - Ledger: http://localhost:3250`);
|
||||
console.log(` - Incidents: http://localhost:3303`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* LandveX Gateway — HTTP Proxy
|
||||
* Routes requests to backend services
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
|
||||
export function proxyRequest(req, res, target) {
|
||||
const options = {
|
||||
hostname: target.host,
|
||||
port: target.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `${target.host}:${target.port}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
console.error(`[PROXY ERROR] ${target.name}: ${err.message}`);
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: 'Bad Gateway',
|
||||
service: target.name,
|
||||
message: err.message,
|
||||
}));
|
||||
});
|
||||
|
||||
proxyReq.on('timeout', () => {
|
||||
proxyReq.destroy();
|
||||
res.statusCode = 504;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: 'Gateway Timeout',
|
||||
service: target.name,
|
||||
message: 'Request timed out',
|
||||
}));
|
||||
});
|
||||
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
|
||||
export async function checkServiceHealth(service) {
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request({
|
||||
hostname: service.host,
|
||||
port: service.port,
|
||||
path: service.healthPath,
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
}, (res) => {
|
||||
resolve({
|
||||
healthy: res.statusCode === 200,
|
||||
latency: Date.now() - start,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
resolve({
|
||||
healthy: false,
|
||||
latency: Date.now() - start,
|
||||
error: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
healthy: false,
|
||||
latency: Date.now() - start,
|
||||
error: 'Timeout',
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//**
|
||||
* LandveX Gateway — HTTP Proxy
|
||||
* Routes requests to backend services
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { ServiceConfig } from './config';
|
||||
|
||||
export function proxyRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
target: ServiceConfig
|
||||
): void {
|
||||
const options: http.RequestOptions = {
|
||||
hostname: target.host,
|
||||
port: target.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `${target.host}:${target.port}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
console.error(`[PROXY ERROR] ${target.name}: ${err.message}`);
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: 'Bad Gateway',
|
||||
service: target.name,
|
||||
message: err.message,
|
||||
}));
|
||||
});
|
||||
|
||||
proxyReq.on('timeout', () => {
|
||||
proxyReq.destroy();
|
||||
res.statusCode = 504;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
error: 'Gateway Timeout',
|
||||
service: target.name,
|
||||
message: 'Request timed out',
|
||||
}));
|
||||
});
|
||||
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
|
||||
export async function checkServiceHealth(service: ServiceConfig): Promise<{ healthy: boolean; latency: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request({
|
||||
hostname: service.host,
|
||||
port: service.port,
|
||||
path: service.healthPath,
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
}, (res) => {
|
||||
resolve({
|
||||
healthy: res.statusCode === 200,
|
||||
latency: Date.now() - start,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
resolve({
|
||||
healthy: false,
|
||||
latency: Date.now() - start,
|
||||
error: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
healthy: false,
|
||||
latency: Date.now() - start,
|
||||
error: 'Timeout',
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@landvex/operations",
|
||||
"version": "0.1.0",
|
||||
"description": "LandveX Operations API — Government customers, billing, contracts",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"pg": "^8.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/pg": "^8.10.0",
|
||||
"typescript": "^5.1.0",
|
||||
"ts-node": "^10.9.0",
|
||||
"jest": "^29.5.0",
|
||||
"@types/jest": "^29.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* LandveX Operations API
|
||||
*
|
||||
* Government customers, billing, contracts, self-service
|
||||
* Port: 3005
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { handleRequest } from './router.mjs';
|
||||
|
||||
const PORT = process.env.PORT || 3005;
|
||||
|
||||
const server = http.createServer(handleRequest);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🏛️ LandveX Operations API running on port ${PORT}`);
|
||||
console.log(`📋 Endpoints:`);
|
||||
console.log(` GET /health → Health check`);
|
||||
console.log(` GET /api/v1/government/me → My profile`);
|
||||
console.log(` PUT /api/v1/government/payment → Update payment settings`);
|
||||
console.log(` GET /api/v1/government/budget → Budget overview`);
|
||||
console.log(` POST /api/v1/government/areas → Add watch area`);
|
||||
console.log(` GET /api/v1/government/areas → List watch areas`);
|
||||
console.log(` POST /api/v1/government/users → Invite user`);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* LandveX Operations API
|
||||
*
|
||||
* Government customers, billing, contracts, self-service
|
||||
* Port: 3005
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { handleRequest } from './router.js';
|
||||
|
||||
const PORT = process.env.PORT || 3005;
|
||||
|
||||
const server = http.createServer(handleRequest);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🏛️ LandveX Operations API running on port ${PORT}`);
|
||||
console.log(`📋 Endpoints:`);
|
||||
console.log(` GET /health → Health check`);
|
||||
console.log(` GET /api/v1/government/me → My profile`);
|
||||
console.log(` PUT /api/v1/government/payment → Update payment settings`);
|
||||
console.log(` GET /api/v1/government/budget → Budget overview`);
|
||||
console.log(` POST /api/v1/government/areas → Add watch area`);
|
||||
console.log(` GET /api/v1/government/areas → List watch areas`);
|
||||
console.log(` POST /api/v1/government/users → Invite user`);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* LandveX Operations API — Router
|
||||
* Zero-dependency HTTP router
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
|
||||
// In-memory store (replace with PostgreSQL)
|
||||
const customers = {};
|
||||
const watchAreas = {};
|
||||
const users = {};
|
||||
|
||||
export function handleRequest(req, res) {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
const path = url.pathname;
|
||||
|
||||
// Health check
|
||||
if (path === '/health') {
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
version: '0.1.0-operations',
|
||||
service: 'operations',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Government customer endpoints
|
||||
if (path === '/api/v1/government/me') {
|
||||
handleGetProfile(req, res);
|
||||
} else if (path === '/api/v1/government/payment' && req.method === 'PUT') {
|
||||
handleUpdatePayment(req, res);
|
||||
} else if (path === '/api/v1/government/budget') {
|
||||
handleGetBudget(req, res);
|
||||
} else if (path === '/api/v1/government/areas' && req.method === 'POST') {
|
||||
handleAddArea(req, res);
|
||||
} else if (path === '/api/v1/government/areas' && req.method === 'GET') {
|
||||
handleListAreas(req, res);
|
||||
} else if (path === '/api/v1/government/users' && req.method === 'POST') {
|
||||
handleInviteUser(req, res);
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Not Found', path }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleGetProfile(req, res) {
|
||||
const profile = {
|
||||
id: 'gov-001',
|
||||
orgNumber: '212000-0142',
|
||||
name: 'Stockholms stad',
|
||||
type: 'kommun',
|
||||
address: 'Stockholms stadshus, 111 83 Stockholm',
|
||||
contactEmail: 'inkop@stockholm.se',
|
||||
paymentSettings: {
|
||||
method: 'invoice',
|
||||
invoiceAddress: 'Stockholms stad, FE 101, 838 73 Frösön',
|
||||
reference: 'Avd. Gatu- och fastighetskontoret',
|
||||
ocrNumber: '2120000142',
|
||||
},
|
||||
budgetLimits: {
|
||||
monthly: 50000,
|
||||
yearly: 600000,
|
||||
alertAtPercent: 80,
|
||||
blockAtPercent: 100,
|
||||
},
|
||||
createdAt: '2024-01-15T10:00:00Z',
|
||||
};
|
||||
|
||||
res.end(JSON.stringify(profile));
|
||||
}
|
||||
|
||||
function handleUpdatePayment(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Payment settings updated',
|
||||
settings: data,
|
||||
}));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetBudget(req, res) {
|
||||
const budget = {
|
||||
monthlyLimit: 50000,
|
||||
monthlyUsed: 32450,
|
||||
monthlyRemaining: 17550,
|
||||
percentUsed: 64.9,
|
||||
yearlyLimit: 600000,
|
||||
yearlyUsed: 187340,
|
||||
yearlyRemaining: 412660,
|
||||
yearlyPercentUsed: 31.2,
|
||||
alertTriggered: false,
|
||||
blockTriggered: false,
|
||||
forecast: {
|
||||
projectedYearly: 582000,
|
||||
status: 'on_track',
|
||||
},
|
||||
breakdown: [
|
||||
{ category: 'Road Inspection', amount: 12400 },
|
||||
{ category: 'Bridge Survey', amount: 8900 },
|
||||
{ category: 'Property Scan', amount: 6750 },
|
||||
{ category: 'Emergency Call', amount: 4400 },
|
||||
],
|
||||
};
|
||||
|
||||
res.end(JSON.stringify(budget));
|
||||
}
|
||||
|
||||
function handleAddArea(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const area = {
|
||||
id: `area-${Date.now()}`,
|
||||
...data,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const customerId = 'gov-001';
|
||||
if (!watchAreas[customerId]) watchAreas[customerId] = [];
|
||||
watchAreas[customerId].push(area);
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify(area));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleListAreas(req, res) {
|
||||
const customerId = 'gov-001';
|
||||
const areas = watchAreas[customerId] || [];
|
||||
|
||||
res.end(JSON.stringify({
|
||||
areas,
|
||||
count: areas.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleInviteUser(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const user = {
|
||||
id: `user-${Date.now()}`,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
role: data.role || 'viewer',
|
||||
invitedAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
const customerId = 'gov-001';
|
||||
if (!users[customerId]) users[customerId] = [];
|
||||
users[customerId].push(user);
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify(user));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* LandveX Operations API — Router
|
||||
* Zero-dependency HTTP router
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
|
||||
// In-memory store (replace with PostgreSQL)
|
||||
const customers: Record<string, any> = {};
|
||||
const watchAreas: Record<string, any[]> = {};
|
||||
const users: Record<string, any[]> = {};
|
||||
|
||||
export function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
const path = url.pathname;
|
||||
|
||||
// Health check
|
||||
if (path === '/health') {
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
version: '0.1.0-operations',
|
||||
service: 'operations',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Government customer endpoints
|
||||
if (path === '/api/v1/government/me') {
|
||||
handleGetProfile(req, res);
|
||||
} else if (path === '/api/v1/government/payment' && req.method === 'PUT') {
|
||||
handleUpdatePayment(req, res);
|
||||
} else if (path === '/api/v1/government/budget') {
|
||||
handleGetBudget(req, res);
|
||||
} else if (path === '/api/v1/government/areas' && req.method === 'POST') {
|
||||
handleAddArea(req, res);
|
||||
} else if (path === '/api/v1/government/areas' && req.method === 'GET') {
|
||||
handleListAreas(req, res);
|
||||
} else if (path === '/api/v1/government/users' && req.method === 'POST') {
|
||||
handleInviteUser(req, res);
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: 'Not Found', path }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleGetProfile(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
// Mock profile — in real implementation, extract from JWT
|
||||
const profile = {
|
||||
id: 'gov-001',
|
||||
orgNumber: '212000-0142',
|
||||
name: 'Stockholms stad',
|
||||
type: 'kommun',
|
||||
address: 'Stockholms stadshus, 111 83 Stockholm',
|
||||
contactEmail: 'inkop@stockholm.se',
|
||||
paymentSettings: {
|
||||
method: 'invoice',
|
||||
invoiceAddress: 'Stockholms stad, FE 101, 838 73 Frösön',
|
||||
reference: 'Avd. Gatu- och fastighetskontoret',
|
||||
ocrNumber: '2120000142',
|
||||
},
|
||||
budgetLimits: {
|
||||
monthly: 50000,
|
||||
yearly: 600000,
|
||||
alertAtPercent: 80,
|
||||
blockAtPercent: 100,
|
||||
},
|
||||
createdAt: '2024-01-15T10:00:00Z',
|
||||
};
|
||||
|
||||
res.end(JSON.stringify(profile));
|
||||
}
|
||||
|
||||
function handleUpdatePayment(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Payment settings updated',
|
||||
settings: data,
|
||||
}));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetBudget(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const budget = {
|
||||
monthlyLimit: 50000,
|
||||
monthlyUsed: 32450,
|
||||
monthlyRemaining: 17550,
|
||||
percentUsed: 64.9,
|
||||
yearlyLimit: 600000,
|
||||
yearlyUsed: 187340,
|
||||
yearlyRemaining: 412660,
|
||||
yearlyPercentUsed: 31.2,
|
||||
alertTriggered: false,
|
||||
blockTriggered: false,
|
||||
forecast: {
|
||||
projectedYearly: 582000,
|
||||
status: 'on_track',
|
||||
},
|
||||
breakdown: [
|
||||
{ category: 'Road Inspection', amount: 12400 },
|
||||
{ category: 'Bridge Survey', amount: 8900 },
|
||||
{ category: 'Property Scan', amount: 6750 },
|
||||
{ category: 'Emergency Call', amount: 4400 },
|
||||
],
|
||||
};
|
||||
|
||||
res.end(JSON.stringify(budget));
|
||||
}
|
||||
|
||||
function handleAddArea(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const area = {
|
||||
id: `area-${Date.now()}`,
|
||||
...data,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const customerId = 'gov-001';
|
||||
if (!watchAreas[customerId]) watchAreas[customerId] = [];
|
||||
watchAreas[customerId].push(area);
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify(area));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleListAreas(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const customerId = 'gov-001';
|
||||
const areas = watchAreas[customerId] || [];
|
||||
|
||||
res.end(JSON.stringify({
|
||||
areas,
|
||||
count: areas.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleInviteUser(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const user = {
|
||||
id: `user-${Date.now()}`,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
role: data.role || 'viewer',
|
||||
invitedAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
const customerId = 'gov-001';
|
||||
if (!users[customerId]) users[customerId] = [];
|
||||
users[customerId].push(user);
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end(JSON.stringify(user));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user