feat: multi-tenant credential isolation + architecture docs
- Add src/multitenancy/ with AES-256-GCM credential store, WhatsApp webhook router (phone_number_id -> customerId), and per-customer audit log (90-day Redis TTL) - Add src/billing/ with plan definitions and meterMiddleware that resolves API key -> Customer object with getCredential() closure - Refactor all src/clients/* to accept optional customer param, falling back to env vars for backward compat with single-user mode - Thread customer through handleToolCall(name, args, customer?) - Add customers table to MySQL schema initDatabase() - Add /webhook/whatsapp (immediate 200 + async routing) and /api/connect/* onboarding endpoints to index.ts - Add Redis 7 to docker-compose.yml; add REDIS_URL and CREDENTIAL_ENCRYPTION_KEY to hermes-k8s.yaml - Add product/incubation/ with architecture write-up and PlantUML diagrams (system architecture + 5 user flows) - Extend OpenAPI spec in manifest.ts with all platform endpoints Verification: 3 isolation tests (credential, webhook routing, audit log) passed against live Redis. Deployed to hermes.squaremcp.com. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
118
src/index.ts
118
src/index.ts
@@ -12,6 +12,9 @@ import {
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { tools, handleToolCall } from './tools.js';
|
||||
import { getManifest, getOpenApiSpec } from './manifest.js';
|
||||
import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
|
||||
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
|
||||
import { meterMiddleware, type Customer } from './billing/middleware.js';
|
||||
import {
|
||||
registerClient,
|
||||
getClient,
|
||||
@@ -580,6 +583,121 @@ app.get('/api/whatsapp/templates', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── WhatsApp webhook (multi-tenant) ─────────────────────────────
|
||||
async function handleInboundWhatsAppMessage(event: RoutedWebhookEvent): Promise<void> {
|
||||
console.log(`[webhook/whatsapp] inbound message from=${event.message.from} customer=${event.customerId} type=${event.message.type}`);
|
||||
// Future: route to customer's agent or queue for processing
|
||||
}
|
||||
|
||||
// WhatsApp webhook verification (GET)
|
||||
app.get('/webhook/whatsapp', (req, res) => {
|
||||
const mode = req.query['hub.mode'];
|
||||
const token = req.query['hub.verify_token'];
|
||||
const challenge = req.query['hub.challenge'];
|
||||
|
||||
if (mode === 'subscribe' && token === process.env.WA_VERIFY_TOKEN) {
|
||||
res.status(200).send(challenge);
|
||||
} else {
|
||||
res.status(403).send('Forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
// WhatsApp webhook delivery (POST) — multi-tenant routed
|
||||
app.post('/webhook/whatsapp', express.json(), async (req, res) => {
|
||||
// Always acknowledge immediately to prevent Meta retries (20s window)
|
||||
res.status(200).send('EVENT_RECEIVED');
|
||||
|
||||
try {
|
||||
const events = await routeWhatsAppWebhook(req.body as Record<string, unknown>);
|
||||
for (const event of events) {
|
||||
await handleInboundWhatsAppMessage(event);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[webhook/whatsapp] routing error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Customer onboarding endpoints ───────────────────────────────
|
||||
|
||||
// Connect WhatsApp — called after customer enters their Meta credentials
|
||||
app.post('/api/connect/whatsapp', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const { phoneNumberId, accessToken, businessAccountId } = req.body as Record<string, string>;
|
||||
|
||||
if (!phoneNumberId || !accessToken || !businessAccountId) {
|
||||
res.status(400).json({ error: 'missing_fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
await storeCredential(customer.id, 'whatsapp', { phoneNumberId, accessToken, businessAccountId });
|
||||
await registerWhatsAppNumber(customer.id, phoneNumberId);
|
||||
|
||||
res.json({ connected: true, platform: 'whatsapp' });
|
||||
});
|
||||
|
||||
// Connect email (IMAP/SMTP)
|
||||
app.post('/api/connect/email', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const { host, port, user, password, smtpHost, smtpPort } = req.body as Record<string, string>;
|
||||
|
||||
if (!host || !port || !user || !password) {
|
||||
res.status(400).json({ error: 'missing_fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
await storeCredential(customer.id, 'email', {
|
||||
host,
|
||||
port: parseInt(port, 10),
|
||||
user,
|
||||
password,
|
||||
smtpHost,
|
||||
smtpPort: smtpPort ? parseInt(smtpPort, 10) : undefined,
|
||||
});
|
||||
|
||||
res.json({ connected: true, platform: 'email' });
|
||||
});
|
||||
|
||||
// Connect OAuth platforms (LinkedIn, Telegram, Discord, Instagram, Twitter)
|
||||
app.post('/api/connect/:platform', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const platform = req.params.platform as Platform;
|
||||
const { accessToken, refreshToken, expiresAt, scope } = req.body as Record<string, string>;
|
||||
|
||||
const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter'];
|
||||
if (!validPlatforms.includes(platform)) {
|
||||
res.status(400).json({ error: 'unknown_platform' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
res.status(400).json({ error: 'missing_fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
await storeCredential(customer.id, platform, {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt: expiresAt ? parseInt(expiresAt, 10) : undefined,
|
||||
scope,
|
||||
});
|
||||
|
||||
res.json({ connected: true, platform });
|
||||
});
|
||||
|
||||
// Get connection status for a customer
|
||||
app.get('/api/connections', meterMiddleware, async (req, res) => {
|
||||
const customer = (req as unknown as { customer: Customer }).customer;
|
||||
const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'obsidian'];
|
||||
|
||||
const status: Record<string, boolean> = {};
|
||||
for (const platform of platforms) {
|
||||
const cred = await customer.getCredential(platform);
|
||||
status[platform] = cred !== null;
|
||||
}
|
||||
|
||||
res.json({ customerId: customer.id, connections: status });
|
||||
});
|
||||
|
||||
// ── LinkedIn REST endpoints ─────────────────────────────────────
|
||||
app.get('/api/linkedin/profile', requireAuth, async (req, res) => {
|
||||
const account = req.query.account as string | undefined;
|
||||
|
||||
Reference in New Issue
Block a user