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

@@ -1,4 +1,5 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import type { Customer } from './billing/middleware.js';
import { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js';
import { sendEmail, createDraft } from './smtp.js';
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js';
@@ -206,7 +207,7 @@ export const tools: Tool[] = [
to: { type: 'string', description: 'Recipient phone number in international format' },
template_name: { type: 'string', description: 'Name of the approved WhatsApp template' },
language: { type: 'string', description: 'Template language code (default: "en")' },
components: { type: 'array', description: 'Template components (header, body, buttons) with parameters' },
components: { type: 'array', items: { type: 'object' }, description: 'Template components (header, body, buttons) with parameters' },
account: { type: 'string', description: 'Which WhatsApp account to use (default: "default")' },
},
required: ['to', 'template_name'],
@@ -524,7 +525,8 @@ function acct(args: Record<string, unknown>): Account {
export async function handleToolCall(
name: string,
args: Record<string, unknown>
args: Record<string, unknown>,
customer?: Customer
): Promise<{ content: Array<{ type: string; text: string }> }> {
console.log(`[tool] ${name}`, JSON.stringify(args));
const t0 = Date.now();
@@ -593,7 +595,7 @@ export async function handleToolCall(
to: args.to as string,
message: args.message as string,
account: args.account as string | undefined,
});
}, customer);
break;
case 'whatsapp_send_template':
@@ -603,27 +605,27 @@ export async function handleToolCall(
language: args.language as string | undefined,
components: args.components as unknown[] | undefined,
account: args.account as string | undefined,
});
}, customer);
break;
case 'whatsapp_get_message_status':
result = await getMessageStatus({
message_id: args.message_id as string,
account: args.account as string | undefined,
});
}, customer);
break;
case 'whatsapp_list_templates':
result = await listTemplates({
account: args.account as string | undefined,
});
}, customer);
break;
// ── LinkedIn ───────────────────────────────────────────────
case 'linkedin_get_profile':
result = await getLinkedInProfile({
account: args.account as string | undefined,
});
}, customer);
break;
case 'linkedin_create_post':
@@ -631,14 +633,14 @@ export async function handleToolCall(
text: args.text as string,
visibility: (args.visibility as 'PUBLIC' | 'CONNECTIONS') ?? 'PUBLIC',
account: args.account as string | undefined,
});
}, customer);
break;
case 'linkedin_search_connections':
result = await searchConnections({
keywords: args.keywords as string | undefined,
account: args.account as string | undefined,
});
}, customer);
break;
case 'linkedin_send_message':
@@ -646,14 +648,14 @@ export async function handleToolCall(
recipient_id: args.recipient_id as string,
message: args.message as string,
account: args.account as string | undefined,
});
}, customer);
break;
// ── Telegram ───────────────────────────────────────────────
case 'telegram_get_me':
result = await getTelegramMe({
account: args.account as string | undefined,
});
}, customer);
break;
case 'telegram_send_message':
@@ -662,7 +664,7 @@ export async function handleToolCall(
text: args.text as string,
parse_mode: (args.parse_mode as 'HTML' | 'Markdown' | 'MarkdownV2') ?? undefined,
account: args.account as string | undefined,
});
}, customer);
break;
case 'telegram_send_photo':
@@ -671,41 +673,41 @@ export async function handleToolCall(
photo: args.photo as string,
caption: args.caption as string | undefined,
account: args.account as string | undefined,
});
}, customer);
break;
case 'telegram_get_updates':
result = await getTelegramUpdates({
limit: (args.limit as number) ?? 10,
account: args.account as string | undefined,
});
}, customer);
break;
case 'telegram_get_chat':
result = await getTelegramChat({
chat_id: args.chat_id as string | number,
account: args.account as string | undefined,
});
}, customer);
break;
// ── Discord ─────────────────────────────────────────────────
case 'discord_get_me':
result = await getDiscordMe({
account: args.account as string | undefined,
});
}, customer);
break;
case 'discord_get_guilds':
result = await getGuilds({
account: args.account as string | undefined,
});
}, customer);
break;
case 'discord_get_channels':
result = await getChannels({
guild_id: args.guild_id as string,
account: args.account as string | undefined,
});
}, customer);
break;
case 'discord_send_message':
@@ -713,7 +715,7 @@ export async function handleToolCall(
channel_id: args.channel_id as string,
content: args.content as string,
account: args.account as string | undefined,
});
}, customer);
break;
case 'discord_get_messages':
@@ -721,21 +723,21 @@ export async function handleToolCall(
channel_id: args.channel_id as string,
limit: (args.limit as number) ?? 10,
account: args.account as string | undefined,
});
}, customer);
break;
// ── Instagram ───────────────────────────────────────────────
case 'instagram_get_profile':
result = await getInstagramProfile({
account: args.account as string | undefined,
});
}, customer);
break;
case 'instagram_get_media':
result = await getInstagramMedia({
limit: (args.limit as number) ?? 10,
account: args.account as string | undefined,
});
}, customer);
break;
case 'instagram_create_post':
@@ -743,7 +745,7 @@ export async function handleToolCall(
image_url: args.image_url as string,
caption: args.caption as string | undefined,
account: args.account as string | undefined,
});
}, customer);
break;
// ── Twitter/X ───────────────────────────────────────────────
@@ -752,14 +754,14 @@ export async function handleToolCall(
query: args.query as string,
max_results: (args.max_results as number) ?? 10,
account: args.account as string | undefined,
});
}, customer);
break;
case 'twitter_get_user_profile':
result = await getUserProfile({
username: args.username as string,
account: args.account as string | undefined,
});
}, customer);
break;
case 'twitter_get_user_tweets':
@@ -767,14 +769,14 @@ export async function handleToolCall(
username: args.username as string,
max_results: (args.max_results as number) ?? 10,
account: args.account as string | undefined,
});
}, customer);
break;
case 'twitter_create_tweet':
result = await createTweet({
text: args.text as string,
account: args.account as string | undefined,
});
}, customer);
break;
// Legacy Yahoo-prefixed names — keep working for any cached Claude sessions