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

@@ -186,6 +186,329 @@ export function getOpenApiSpec(serverUrl: string) {
},
},
},
// ── Email ───────────────────────────────────────────────────
'/api/email/profile': {
get: {
operationId: 'get_profile',
summary: 'Get email account profile',
parameters: [
{ name: 'account', in: 'query', schema: { type: 'string' }, description: 'Mailbox account (yahoo, fetcherpay, garfield, sales, leads, founder, gmail)' },
],
responses: { '200': { description: 'Profile info' } },
},
},
'/api/email/search': {
get: {
operationId: 'search_messages',
summary: 'Search email messages',
parameters: [
{ name: 'q', in: 'query', required: true, schema: { type: 'string' } },
{ name: 'maxResults', in: 'query', schema: { type: 'integer', default: 20 } },
{ name: 'account', in: 'query', schema: { type: 'string' } },
{ name: 'folder', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Search results' } },
},
},
'/api/email/read': {
get: {
operationId: 'read_message',
summary: 'Read email by UID',
parameters: [
{ name: 'uid', in: 'query', required: true, schema: { type: 'integer' } },
{ name: 'account', in: 'query', schema: { type: 'string' } },
{ name: 'folder', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Message body' } },
},
},
'/api/email/send': {
post: {
operationId: 'send_email',
summary: 'Send email',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['to', 'subject', 'body'],
properties: {
to: { type: 'string' },
subject: { type: 'string' },
body: { type: 'string' },
account: { type: 'string' },
},
},
},
},
},
responses: { '200': { description: 'Email sent' } },
},
},
// ── WhatsApp ────────────────────────────────────────────────
'/api/whatsapp/send': {
post: {
operationId: 'whatsapp_send_message',
summary: 'Send WhatsApp message',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['to', 'message'],
properties: {
to: { type: 'string', description: 'Phone number in international format' },
message: { type: 'string' },
account: { type: 'string' },
},
},
},
},
},
responses: { '200': { description: 'Message sent' } },
},
},
'/api/whatsapp/template': {
post: {
operationId: 'whatsapp_send_template',
summary: 'Send WhatsApp template',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['to', 'template_name'],
properties: {
to: { type: 'string' },
template_name: { type: 'string' },
language: { type: 'string' },
components: { type: 'array', items: { type: 'object' } },
account: { type: 'string' },
},
},
},
},
},
responses: { '200': { description: 'Template sent' } },
},
},
'/api/whatsapp/templates': {
get: {
operationId: 'whatsapp_list_templates',
summary: 'List WhatsApp templates',
parameters: [
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Template list' } },
},
},
// ── LinkedIn ────────────────────────────────────────────────
'/api/linkedin/profile': {
get: {
operationId: 'linkedin_get_profile',
summary: 'Get LinkedIn profile',
parameters: [
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Profile info' } },
},
},
'/api/linkedin/post': {
post: {
operationId: 'linkedin_create_post',
summary: 'Create LinkedIn post',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['text'],
properties: {
text: { type: 'string' },
visibility: { type: 'string', enum: ['PUBLIC', 'CONNECTIONS'] },
account: { type: 'string' },
},
},
},
},
},
responses: { '200': { description: 'Post created' } },
},
},
// ── Telegram ────────────────────────────────────────────────
'/api/telegram/message': {
post: {
operationId: 'telegram_send_message',
summary: 'Send Telegram message',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['chat_id', 'text'],
properties: {
chat_id: { type: 'string' },
text: { type: 'string' },
parse_mode: { type: 'string' },
account: { type: 'string' },
},
},
},
},
},
responses: { '200': { description: 'Message sent' } },
},
},
'/api/telegram/updates': {
get: {
operationId: 'telegram_get_updates',
summary: 'Get Telegram updates',
parameters: [
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Updates list' } },
},
},
// ── Discord ─────────────────────────────────────────────────
'/api/discord/guilds': {
get: {
operationId: 'discord_get_guilds',
summary: 'List Discord servers',
parameters: [
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Guild list' } },
},
},
'/api/discord/message': {
post: {
operationId: 'discord_send_message',
summary: 'Send Discord message',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['channel_id', 'content'],
properties: {
channel_id: { type: 'string' },
content: { type: 'string' },
account: { type: 'string' },
},
},
},
},
},
responses: { '200': { description: 'Message sent' } },
},
},
'/api/discord/messages': {
get: {
operationId: 'discord_get_messages',
summary: 'Get Discord messages',
parameters: [
{ name: 'channel_id', in: 'query', required: true, schema: { type: 'string' } },
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Message list' } },
},
},
// ── Instagram ───────────────────────────────────────────────
'/api/instagram/profile': {
get: {
operationId: 'instagram_get_profile',
summary: 'Get Instagram profile',
parameters: [
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Profile info' } },
},
},
'/api/instagram/media': {
get: {
operationId: 'instagram_get_media',
summary: 'Get Instagram media',
parameters: [
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Media list' } },
},
},
'/api/instagram/post': {
post: {
operationId: 'instagram_create_post',
summary: 'Create Instagram post',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['image_url'],
properties: {
image_url: { type: 'string' },
caption: { type: 'string' },
account: { type: 'string' },
},
},
},
},
},
responses: { '200': { description: 'Post created' } },
},
},
// ── Twitter/X ───────────────────────────────────────────────
'/api/twitter/search': {
get: {
operationId: 'twitter_search_tweets',
summary: 'Search tweets',
parameters: [
{ name: 'query', in: 'query', required: true, schema: { type: 'string' } },
{ name: 'max_results', in: 'query', schema: { type: 'integer' } },
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Tweet list' } },
},
},
'/api/twitter/user': {
get: {
operationId: 'twitter_get_user_profile',
summary: 'Get Twitter user profile',
parameters: [
{ name: 'username', in: 'query', required: true, schema: { type: 'string' } },
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'User profile' } },
},
},
'/api/twitter/tweets': {
get: {
operationId: 'twitter_get_user_tweets',
summary: 'Get user tweets',
parameters: [
{ name: 'username', in: 'query', required: true, schema: { type: 'string' } },
{ name: 'max_results', in: 'query', schema: { type: 'integer' } },
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Tweet list' } },
},
},
},
};
}
@@ -428,7 +751,7 @@ export function getManifest(serverUrl: string, authEnabled: boolean) {
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")' },
},
},