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:
104
src/multitenancy/webhook-router.ts
Normal file
104
src/multitenancy/webhook-router.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createClient } from 'redis';
|
||||
import { getCredential, WhatsAppCredentials } from './credential-store.js';
|
||||
|
||||
const redis = createClient({ url: process.env.REDIS_URL });
|
||||
redis.connect().catch((err) => console.error('[webhook-router] Redis connect error:', err));
|
||||
|
||||
// Call this at customer onboarding when they connect their WhatsApp Business number
|
||||
export async function registerWhatsAppNumber(
|
||||
customerId: string,
|
||||
phoneNumberId: string
|
||||
): Promise<void> {
|
||||
await redis.set(`wa_phone_id:${phoneNumberId}`, customerId);
|
||||
}
|
||||
|
||||
export async function unregisterWhatsAppNumber(phoneNumberId: string): Promise<void> {
|
||||
await redis.del(`wa_phone_id:${phoneNumberId}`);
|
||||
}
|
||||
|
||||
export async function resolveCustomerFromPhoneNumberId(
|
||||
phoneNumberId: string
|
||||
): Promise<string | null> {
|
||||
return redis.get(`wa_phone_id:${phoneNumberId}`);
|
||||
}
|
||||
|
||||
// WhatsApp Cloud API webhook payload types
|
||||
interface WhatsAppWebhookEntry {
|
||||
id: string;
|
||||
changes: Array<{
|
||||
value: {
|
||||
messaging_product: string;
|
||||
metadata: {
|
||||
display_phone_number: string;
|
||||
phone_number_id: string;
|
||||
};
|
||||
messages?: WhatsAppInboundMessage[];
|
||||
statuses?: WhatsAppStatus[];
|
||||
};
|
||||
field: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WhatsAppInboundMessage {
|
||||
from: string;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
text?: { body: string };
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface WhatsAppStatus {
|
||||
id: string;
|
||||
status: string;
|
||||
timestamp: string;
|
||||
recipient_id: string;
|
||||
}
|
||||
|
||||
export interface RoutedWebhookEvent {
|
||||
customerId: string;
|
||||
phoneNumberId: string;
|
||||
message: WhatsAppInboundMessage;
|
||||
credentials: WhatsAppCredentials;
|
||||
}
|
||||
|
||||
// Parse the incoming webhook and return one routed event per message
|
||||
export async function routeWhatsAppWebhook(
|
||||
body: Record<string, unknown>
|
||||
): Promise<RoutedWebhookEvent[]> {
|
||||
const events: RoutedWebhookEvent[] = [];
|
||||
|
||||
if (body.object !== 'whatsapp_business_account') return events;
|
||||
|
||||
const entries = (body.entry as WhatsAppWebhookEntry[]) ?? [];
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const change of entry.changes) {
|
||||
if (change.field !== 'messages') continue;
|
||||
|
||||
const { phone_number_id } = change.value.metadata;
|
||||
const messages = change.value.messages ?? [];
|
||||
|
||||
if (messages.length === 0) continue;
|
||||
|
||||
// Resolve which customer owns this phone number
|
||||
const customerId = await resolveCustomerFromPhoneNumberId(phone_number_id);
|
||||
if (!customerId) {
|
||||
console.warn(`[webhook-router] Unroutable WhatsApp message to phone_number_id=${phone_number_id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load that customer's WhatsApp credentials
|
||||
const credentials = await getCredential<WhatsAppCredentials>(customerId, 'whatsapp');
|
||||
if (!credentials) {
|
||||
console.error(`[webhook-router] No WhatsApp credentials for customerId=${customerId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
events.push({ customerId, phoneNumberId: phone_number_id, message, credentials });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
Reference in New Issue
Block a user