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:
56
src/tools.ts
56
src/tools.ts
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user