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:
Garfield
2026-05-08 11:27:29 -04:00
parent 59501f11f1
commit 8d62e4d9d5
21 changed files with 1863 additions and 346 deletions

View File

@@ -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;