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

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