diff --git a/docker-compose.yml b/docker-compose.yml index 2c938a8..5ba8d72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,14 @@ services: + redis: + image: redis:7-alpine + container_name: hermes-redis + restart: unless-stopped + networks: + - hermes-net + ports: + - "6379:6379" + command: redis-server --save "" + hermes: build: . container_name: hermes-mcp @@ -6,6 +16,9 @@ services: env_file: .env environment: - MYSQL_HOST=mysql + - REDIS_URL=redis://redis:6379 + depends_on: + - redis volumes: - /home/garfield/obsidian/vaults:/vaults extra_hosts: diff --git a/package-lock.json b/package-lock.json index fa0fb54..761e5a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "express": "^4.18.0", "imapflow": "^1.0.0", "mysql2": "^3.14.0", - "nodemailer": "^6.9.0" + "nodemailer": "^6.9.0", + "redis": "^5.12.1" }, "devDependencies": { "@types/express": "^4.17.0", @@ -813,6 +814,78 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1107,6 +1180,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2246,6 +2328,22 @@ "node": ">= 12.13.0" } }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/package.json b/package.json index b3ee6cb..76a5c94 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "express": "^4.18.0", "imapflow": "^1.0.0", "mysql2": "^3.14.0", - "nodemailer": "^6.9.0" + "nodemailer": "^6.9.0", + "redis": "^5.12.1" }, "devDependencies": { "@types/express": "^4.17.0", diff --git a/product/incubation/ARCHITECTURE.md b/product/incubation/ARCHITECTURE.md new file mode 100644 index 0000000..0cf4716 --- /dev/null +++ b/product/incubation/ARCHITECTURE.md @@ -0,0 +1,208 @@ +# hermes-mcp — Architecture + +**Version:** post multi-tenancy (2026-05-08) +**Deployed:** hermes.squaremcp.com (MicroK8s) + +--- + +## Overview + +hermes-mcp is a TypeScript/Node.js MCP gateway that gives AI agents (Claude, ChatGPT, opencode) authenticated access to messaging and productivity platforms — WhatsApp, LinkedIn, Telegram, Discord, Instagram, Twitter, email, and Obsidian. + +It was built first as a single-user prototype for the builder, then extended with multi-tenant credential isolation so multiple paying customers can connect their own platform accounts with zero data leakage between them. + +--- + +## Stack + +| Layer | Technology | +|-------|-----------| +| Runtime | Node.js 20, TypeScript, ESM | +| MCP transport | `@modelcontextprotocol/sdk` — Streamable HTTP + SSE | +| HTTP server | Express 4 | +| Database | MySQL 8 (`mysql2`) — OAuth clients, tokens, customers | +| Cache / credential store | Redis 7 (`redis` npm, v5) | +| Deployment | MicroK8s single-node, Traefik/nginx ingress, Let's Encrypt TLS | + +--- + +## Directory structure + +``` +src/ +├── index.ts Express server, MCP sessions, REST endpoints, OAuth +├── tools.ts Tool registry + handleToolCall(name, args, customer?) +├── db.ts MySQL pool init, schema migrations +├── oauth.ts OAuth 2.0 server (DCR, authorize, token) +├── imap.ts Multi-account IMAP email reader +├── smtp.ts Multi-account SMTP email sender +├── manifest.ts OpenAPI + ChatGPT plugin manifest generation +│ +├── clients/ One file per platform +│ ├── whatsapp.ts Meta Cloud API +│ ├── linkedin.ts LinkedIn API v2 +│ ├── telegram.ts Telegram Bot API +│ ├── discord.ts Discord API v10 +│ ├── instagram.ts Meta Graph API (Instagram Business) +│ ├── twitter.ts Twitter API v2 +│ └── obsidian.ts Local filesystem vault +│ +├── multitenancy/ Added 2026-05-08 +│ ├── credential-store.ts AES-256-GCM encrypted credentials in Redis +│ ├── webhook-router.ts WhatsApp phone_number_id → customerId routing +│ └── audit-log.ts Per-customer tool call audit trail (90-day TTL) +│ +└── billing/ Added 2026-05-08 + ├── plans.ts Plan definitions (free/starter/growth/enterprise) + └── middleware.ts Customer resolution + meterMiddleware +``` + +--- + +## Multi-tenancy design + +### Credential isolation + +Each customer's platform tokens are stored encrypted in Redis under a namespaced key: + +``` +creds:{customerId}:{platform} +``` + +Encryption is AES-256-GCM with a 32-byte key from `CREDENTIAL_ENCRYPTION_KEY` (env var). IV and auth tag are prepended to the ciphertext as hex. **The key must never be rotated without first re-encrypting all stored credentials.** + +### Customer resolution + +The `meterMiddleware` resolves an API key to a `Customer` object on every request: + +1. Check Redis cache: `customer:apikey:{apiKey}` (60s TTL) +2. On miss: `SELECT id, plan, active, email FROM customers WHERE api_key = ?` +3. Attach `getCredential()` closure (not cacheable — functions can't be JSON serialized) +4. Write serialisable fields back to Redis cache + +```typescript +interface Customer { + id: string; + plan: PlanKey; + active: boolean; + email: string; + getCredential: (platform: Platform) => Promise; +} +``` + +The credential loader is attached at resolution time, capturing `id` in a closure. Tool handlers call `customer.getCredential('whatsapp')` — they cannot accidentally use the wrong customer's ID. + +### Backward compatibility + +All platform clients have `customer` as an optional second parameter. When absent (single-user mode via `MCP_API_KEY`), they fall back to env vars — the builder's existing setup is unchanged. + +```typescript +export async function sendMessage(args, customer?: Customer) { + if (customer) { + const creds = await customer.getCredential('whatsapp'); + if (!creds) throw new Error('WhatsApp not connected for this account'); + // use creds.phoneNumberId, creds.accessToken + } else { + // read WHATSAPP_DEFAULT_PHONE_NUMBER_ID etc from process.env + } +} +``` + +### WhatsApp webhook routing + +Meta sends all inbound messages for all connected numbers to one webhook endpoint. The router uses a Redis lookup table populated at onboarding: + +``` +wa_phone_id:{phoneNumberId} → customerId +``` + +The webhook endpoint acknowledges immediately (within Meta's 20-second SLA) and routes asynchronously: + +```typescript +app.post('/webhook/whatsapp', express.json(), async (req, res) => { + res.status(200).send('EVENT_RECEIVED'); // sync — never blocked by routing + try { + const events = await routeWhatsAppWebhook(req.body); + for (const event of events) await handleInboundWhatsAppMessage(event); + } catch (err) { console.error(err); } +}); +``` + +### Audit log + +Every tool call (when a `Customer` is present) is logged to Redis: + +``` +log_seq:{customerId}:{date} INCR counter +logs:{customerId}:{date}:{seq} JSON entry, EX 7776000 (90 days) +``` + +The sequence key ensures chronological ordering without ULIDs. No cross-customer query path exists — all retrieval functions require `customerId` as the first argument. + +--- + +## Redis key namespace summary + +| Key | Value | TTL | +|-----|-------|-----| +| `creds:{customerId}:{platform}` | AES-256-GCM encrypted JSON | none (permanent until revoked) | +| `wa_phone_id:{phoneNumberId}` | customerId string | none | +| `customer:apikey:{apiKey}` | JSON (id, plan, active, email) | 60s | +| `log_seq:{customerId}:{date}` | integer counter | 95 days | +| `logs:{customerId}:{date}:{seq}` | JSON AuditEntry | 90 days | + +--- + +## Request paths + +### MCP tool calls (existing single-user) +``` +Claude.ai → POST /mcp → requireAuth(MCP_API_KEY) → handleToolCall(name, args) + → client(args, undefined) + → env vars → Platform API +``` + +### Multi-tenant REST tool calls +``` +Customer → POST /api/whatsapp/send → requireAuth → handleToolCall(name, args) + → client(args, undefined) + → env vars +``` +*(REST endpoints do not yet thread customer — future work)* + +### Multi-tenant onboarding +``` +Customer → POST /api/connect/whatsapp → meterMiddleware → storeCredential() + → registerWhatsAppNumber() +``` + +### Inbound WhatsApp webhook +``` +Meta → POST /webhook/whatsapp → 200 immediately + → routeWhatsAppWebhook(body) + → Redis lookup phone_number_id → customerId + → getCredential(customerId, 'whatsapp') + → handleInboundWhatsAppMessage(event) +``` + +--- + +## PlantUML diagrams + +- [`architecture-system.puml`](./architecture-system.puml) — component and dependency diagram +- [`architecture-userflows.puml`](./architecture-userflows.puml) — sequence diagrams for all 5 flows + +Render with [plantuml.com](https://www.plantuml.com/plantuml/uml/), the PlantUML VS Code extension, or `plantuml -tsvg architecture-system.puml`. + +--- + +## What's not yet done + +| Item | Notes | +|------|-------| +| Customer provisioning | `customers` table exists but needs an INSERT path (Stripe webhook → seed row) | +| MCP session → Customer | MCP calls don't resolve customers; sessions still use single-user env vars | +| Email multi-tenancy | `imap.ts` / `smtp.ts` use Account enum; `customer.getCredential('email')` not wired | +| Usage metering | `meter.ts` not implemented; plan limits not enforced | +| Obsidian per-customer vault | Currently one global vault path from env | +| Key rotation tooling | Script to re-encrypt all `creds:*` keys under a new `CREDENTIAL_ENCRYPTION_KEY` | diff --git a/product/incubation/architecture-system.puml b/product/incubation/architecture-system.puml new file mode 100644 index 0000000..8b89306 --- /dev/null +++ b/product/incubation/architecture-system.puml @@ -0,0 +1,159 @@ +@startuml hermes-mcp-architecture + +skinparam backgroundColor #1a1a2e +skinparam defaultFontColor #e0e0e0 +skinparam defaultFontSize 13 +skinparam arrowColor #888888 +skinparam roundCorner 8 +skinparam shadowing false + +skinparam component { + BackgroundColor #16213e + BorderColor #0f3460 + FontColor #e0e0e0 + ArrowColor #888888 +} +skinparam package { + BackgroundColor #0d1b2a + BorderColor #2a4a6a + FontColor #aaaaaa + FontStyle bold +} +skinparam database { + BackgroundColor #0f2040 + BorderColor #533483 + FontColor #e0e0e0 +} +skinparam cloud { + BackgroundColor #1a0d2e + BorderColor #e94560 + FontColor #e0e0e0 +} +skinparam note { + BackgroundColor #0d1b2a + BorderColor #2a4a6a + FontColor #aaaaaa +} + +title hermes-mcp -- System Architecture (2026-05-08) + +' ── AI Clients ──────────────────────────────────────────────────── +package "AI Clients" { + [Claude.ai\nMCP connector] as claude + [ChatGPT\ncustom GPT] as chatgpt + [opencode / Codex] as opencode +} + +' ── hermes-mcp server ───────────────────────────────────────────── +package "hermes-mcp | Node.js / TypeScript | hermes.squaremcp.com" { + + package "Transports" { + [Streamable HTTP\n/mcp] as mcp_t + [SSE legacy\n/sse] as sse_t + [REST API\n/api/*] as rest_t + [Webhook\n/webhook/whatsapp] as wh_t + } + + package "Auth" { + [requireAuth\nMCP_API_KEY + OAuth Bearer] as req_auth + [meterMiddleware\nAPI key -> Customer] as meter + [OAuth 2.0\n/oauth/*] as oauth + } + + package "Core" { + [handleToolCall\nname, args, customer?] as dispatch + } + + package "Platform Clients src/clients/*" { + [whatsapp.ts] as c_wa + [linkedin.ts] as c_li + [telegram.ts] as c_tg + [discord.ts] as c_dc + [instagram.ts] as c_ig + [twitter.ts] as c_tw + [obsidian.ts] as c_ob + } + + package "Multi-tenancy src/multitenancy/*" { + [credential-store\nAES-256-GCM] as cred_store + [webhook-router\nphone_id -> customerId] as wh_router + [audit-log\n90-day per-customer trail] as audit_log + } + + package "Billing src/billing/*" { + [middleware.ts\nCustomer + meterMiddleware] as billing + [plans.ts\nfree/starter/growth/enterprise] as plans + } +} + +' ── Storage ─────────────────────────────────────────────────────── +database "Redis 7" as redis +note right of redis + creds:{cid}:{platform} (AES-256-GCM encrypted) + wa_phone_id:{phoneId} (-> customerId) + customer:apikey:{key} (60s TTL cache) + logs:{cid}:{date}:{seq} (90-day audit trail) +end note + +database "MySQL 8\nhermes_oauth" as mysql +note right of mysql + customers (id, api_key, plan, active, email) + oauth_clients / oauth_auth_codes / oauth_tokens +end note + +' ── Platform APIs ───────────────────────────────────────────────── +cloud "External Platform APIs" { + [Meta Graph API\nWhatsApp + Instagram] as meta_api + [LinkedIn API v2] as li_api + [Telegram Bot API] as tg_api + [Discord API v10] as dc_api + [Twitter API v2] as tw_api + [Obsidian Vault\nfilesystem] as ob_vault +} + +' ── Connections ─────────────────────────────────────────────────── +claude --> mcp_t : MCP / Bearer +chatgpt --> mcp_t : MCP / Bearer +opencode --> rest_t : REST / x-api-key + +mcp_t --> req_auth +sse_t --> req_auth +rest_t --> req_auth +rest_t --> meter : /api/connect/* +wh_t --> wh_router + +req_auth --> dispatch +meter --> billing : resolve Customer +billing --> mysql : SELECT customers +billing --> redis : customer cache + +dispatch --> c_wa +dispatch --> c_li +dispatch --> c_tg +dispatch --> c_dc +dispatch --> c_ig +dispatch --> c_tw +dispatch --> c_ob + +c_wa --> cred_store : getCredential +c_li --> cred_store : getCredential +c_tg --> cred_store : getCredential +c_dc --> cred_store : getCredential +c_ig --> cred_store : getCredential +c_tw --> cred_store : getCredential + +cred_store --> redis +wh_router --> redis +audit_log --> redis + +c_wa --> meta_api +c_ig --> meta_api +c_li --> li_api +c_tg --> tg_api +c_dc --> dc_api +c_tw --> tw_api +c_ob --> ob_vault + +oauth --> mysql + +@enduml diff --git a/product/incubation/architecture-userflows.puml b/product/incubation/architecture-userflows.puml new file mode 100644 index 0000000..d03dcc2 --- /dev/null +++ b/product/incubation/architecture-userflows.puml @@ -0,0 +1,151 @@ +@startuml hermes-mcp-userflows + +skinparam backgroundColor #1a1a2e +skinparam defaultFontColor #e0e0e0 +skinparam defaultFontSize 12 +skinparam sequenceArrowColor #aaaaaa +skinparam sequenceLifeLineBorderColor #34495e +skinparam sequenceParticipantBorderColor #0f3460 +skinparam sequenceParticipantBackgroundColor #16213e +skinparam sequenceParticipantFontColor #e0e0e0 +skinparam noteBackgroundColor #0d1b2a +skinparam noteBorderColor #533483 +skinparam noteFontColor #e0e0e0 +skinparam sequenceGroupBorderColor #e94560 +skinparam sequenceGroupFontColor #e94560 +skinparam sequenceGroupBodyBackgroundColor #0d0d1a + +title hermes-mcp - User Flows (post multi-tenancy, 2026-05-08) + +' ════════════════════════════════════════════════════════════════ +== Flow 1: Single-User Tool Call (builder prototype - unchanged) == +' ════════════════════════════════════════════════════════════════ + +participant "Claude.ai" as claude #16213e +participant "MCP Transport\n/mcp" as mcp #16213e +participant "requireAuth" as auth #16213e +participant "handleToolCall\n(no customer)" as handler #16213e +participant "Platform Client\nsrc/clients/*" as client #16213e +participant "Platform API" as api #0f3460 + +claude -> mcp : POST /mcp Bearer token +mcp -> auth : validate token +auth -> auth : match MCP_API_KEY env var +auth --> mcp : OK +mcp -> handler : handleToolCall(name, args)\ncustomer = undefined +handler -> client : sendMessage(args, undefined) +note right of client + customer is undefined -> + falls back to env vars + WHATSAPP_DEFAULT_ACCESS_TOKEN etc. +end note +client -> api : POST with env-var token +api --> client : response +client --> claude : tool result + +' ════════════════════════════════════════════════════════════════ +== Flow 2: Customer Onboarding - Connect WhatsApp == +' ════════════════════════════════════════════════════════════════ + +participant "Customer\n(squaremcp.com)" as cust #16213e +participant "POST /api/connect/whatsapp" as conn_ep #16213e +participant "meterMiddleware" as meter #16213e +participant "MySQL\ncustomers table" as mysql #0f3460 +participant "Redis" as redis #0f3460 +participant "credential-store\n(AES-256-GCM)" as cs #16213e +participant "webhook-router" as wr #16213e + +cust -> conn_ep : POST /api/connect/whatsapp\nx-api-key: cust_abc123\n{phoneNumberId, accessToken, businessAccountId} +conn_ep -> meter : resolve customer +meter -> redis : GET customer:apikey:cust_abc123 +redis --> meter : (miss - first request) +meter -> mysql : SELECT id,plan,active,email\nFROM customers WHERE api_key=? +mysql --> meter : {id:'cust_001', plan:'starter', active:true} +meter -> redis : SETEX customer:apikey:cust_abc123 60 {...} +meter --> conn_ep : req.customer = Customer{id:'cust_001', getCredential()} +conn_ep -> cs : storeCredential('cust_001', 'whatsapp', creds) +cs -> cs : randomBytes(12) IV\nAES-256-GCM encrypt +cs -> redis : SET creds:cust_001:whatsapp [encrypted] +conn_ep -> wr : registerWhatsAppNumber('cust_001', phoneNumberId) +wr -> redis : SET wa_phone_id:{phoneNumberId} cust_001 +conn_ep --> cust : 200 {connected: true, platform: 'whatsapp'} + +' ════════════════════════════════════════════════════════════════ +== Flow 3: Multi-Tenant Tool Call (customer sends WhatsApp via MCP/REST) == +' ════════════════════════════════════════════════════════════════ + +participant "Customer\nMCP session" as cust2 #16213e +participant "handleToolCall\n(customer passed)" as handler2 #16213e +participant "whatsapp.ts\nclient" as wac #16213e +participant "credential-store" as cs2 #16213e +participant "Redis" as redis2 #0f3460 +participant "audit-log" as al #16213e +participant "WhatsApp\nGraph API" as wa_api #0f3460 + +cust2 -> handler2 : whatsapp_send_message\n{to, message}\ncustomer = Customer{id:'cust_001'} +handler2 -> wac : sendMessage(args, customer) +wac -> cs2 : customer.getCredential('whatsapp') +cs2 -> redis2 : GET creds:cust_001:whatsapp +redis2 --> cs2 : [AES-256-GCM ciphertext] +cs2 -> cs2 : decrypt -> {phoneNumberId, accessToken, businessAccountId} +cs2 --> wac : WhatsAppCredentials +wac -> wa_api : POST /{phoneNumberId}/messages\nAuthorization: Bearer {accessToken} +wa_api --> wac : {messages: [{id: 'wamid.xxx'}]} +wac -> al : audit.success({to}) +al -> redis2 : SET logs:cust_001:2026-05-08:00000001\nEX 7776000 (90 days) +wac --> cust2 : {success: true, message_id: 'wamid.xxx'} + +' ════════════════════════════════════════════════════════════════ +== Flow 4: Inbound WhatsApp Webhook (Meta -> correct customer) == +' ════════════════════════════════════════════════════════════════ + +participant "Meta\nCloud API" as meta #0f3460 +participant "POST /webhook/whatsapp" as whook #16213e +participant "webhook-router" as wr2 #16213e +participant "Redis" as redis3 #0f3460 +participant "credential-store" as cs3 #16213e +participant "handleInbound\n(stub -> future agent)" as inbound #16213e + +meta -> whook : POST /webhook/whatsapp\n{object:'whatsapp_business_account',\nentry:[{changes:[{value:{metadata:\n{phone_number_id:'111'},messages:[...]}}]}]} +note right of whook + Must respond within 20s + or Meta retries the webhook +end note +whook --> meta : 200 EVENT_RECEIVED <- immediate +whook -> wr2 : routeWhatsAppWebhook(body) [async] +wr2 -> redis3 : GET wa_phone_id:111 +redis3 --> wr2 : 'cust_001' +wr2 -> cs3 : getCredential('cust_001', 'whatsapp') +cs3 -> redis3 : GET creds:cust_001:whatsapp -> decrypt +cs3 --> wr2 : WhatsAppCredentials +wr2 --> whook : RoutedWebhookEvent{customerId, phoneNumberId, message, credentials} +whook -> inbound : handleInboundWhatsAppMessage(event) +note right of inbound + Currently: log only + Future: route to customer's + AI agent / queue +end note + +' ════════════════════════════════════════════════════════════════ +== Flow 5: Customer Checks Connection Status == +' ════════════════════════════════════════════════════════════════ + +participant "Customer" as cust3 #16213e +participant "GET /api/connections" as conn_get #16213e +participant "meterMiddleware" as meter3 #16213e +participant "credential-store" as cs4 #16213e +participant "Redis" as redis4 #0f3460 + +cust3 -> conn_get : GET /api/connections\nx-api-key: cust_abc123 +conn_get -> meter3 : resolve (cache hit this time) +meter3 -> redis4 : GET customer:apikey:cust_abc123 +redis4 --> meter3 : Customer{id:'cust_001'} +meter3 --> conn_get : req.customer attached +loop for each platform in [email, whatsapp, linkedin, telegram, discord, instagram, twitter, obsidian] + conn_get -> cs4 : customer.getCredential(platform) + cs4 -> redis4 : GET creds:cust_001:{platform} + redis4 --> cs4 : value or null +end +conn_get --> cust3 : 200 {customerId:'cust_001',\nconnections:{whatsapp:true, linkedin:false, ...}} + +@enduml diff --git a/src/billing/middleware.ts b/src/billing/middleware.ts new file mode 100644 index 0000000..3e02fa1 --- /dev/null +++ b/src/billing/middleware.ts @@ -0,0 +1,92 @@ +import { createClient } from 'redis'; +import { RowDataPacket } from 'mysql2'; +import { getPool } from '../db.js'; +import { getCredential, Platform, PlatformCredentials } from '../multitenancy/credential-store.js'; +import type { PlanKey } from './plans.js'; +import type { Request, Response, NextFunction } from 'express'; + +const redis = createClient({ url: process.env.REDIS_URL }); +redis.connect().catch((err) => console.error('[billing] Redis connect error:', err)); + +export interface Customer { + id: string; + plan: PlanKey; + active: boolean; + email: string; + // Credential loader — tool handlers call this to get their platform credentials + getCredential: (platform: Platform) => Promise; +} + +interface CustomerRow extends RowDataPacket { + id: string; + plan: PlanKey; + active: boolean; + email: string; +} + +async function resolveCustomer(apiKey: string): Promise { + const cached = await redis.get(`customer:apikey:${apiKey}`); + if (cached) { + const base = JSON.parse(cached) as Omit; + // Re-attach the credential loader (functions can't be cached) + return { + ...base, + getCredential: (platform: Platform) => + getCredential(base.id, platform), + }; + } + + const [rows] = await getPool().query( + 'SELECT id, plan, active, email FROM customers WHERE api_key = ?', + [apiKey] + ); + if (!rows.length) return null; + + const { id, plan, active, email } = rows[0]; + const customer: Customer = { + id, + plan, + active: Boolean(active), + email, + getCredential: (platform: Platform) => + getCredential(id, platform), + }; + + // Cache only the serialisable fields (not the function) + await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify({ id, plan, active, email })); + return customer; +} + +// Express middleware: resolve API key → Customer and attach to req.customer +export async function meterMiddleware( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const apiKey = + (req.headers['x-api-key'] as string | undefined) || + (req.query.key as string | undefined); + + if (!apiKey) { + res.status(401).json({ error: 'Missing API key' }); + return; + } + + const customer = await resolveCustomer(apiKey); + if (!customer) { + res.status(401).json({ error: 'Invalid API key' }); + return; + } + + if (!customer.active) { + res.status(403).json({ error: 'Account suspended' }); + return; + } + + (req as Request & { customer: Customer }).customer = customer; + next(); + } catch (err) { + next(err); + } +} diff --git a/src/billing/plans.ts b/src/billing/plans.ts new file mode 100644 index 0000000..af84692 --- /dev/null +++ b/src/billing/plans.ts @@ -0,0 +1,30 @@ +export type PlanKey = 'free' | 'starter' | 'growth' | 'enterprise'; + +export interface Plan { + name: string; + monthlyCallLimit: number; + platforms: string[]; +} + +export const PLANS: Record = { + free: { + name: 'Free', + monthlyCallLimit: 100, + platforms: ['email', 'obsidian'], + }, + starter: { + name: 'Starter', + monthlyCallLimit: 1000, + platforms: ['email', 'obsidian', 'whatsapp', 'telegram'], + }, + growth: { + name: 'Growth', + monthlyCallLimit: 10000, + platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'], + }, + enterprise: { + name: 'Enterprise', + monthlyCallLimit: -1, + platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'], + }, +}; diff --git a/src/clients/discord.ts b/src/clients/discord.ts index 03a9ec5..9e60d06 100644 --- a/src/clients/discord.ts +++ b/src/clients/discord.ts @@ -1,10 +1,25 @@ +import type { Customer } from '../billing/middleware.js'; +import type { OAuthCredentials } from '../multitenancy/credential-store.js'; +import { createToolAudit } from '../multitenancy/audit-log.js'; + const DISCORD_API_BASE = 'https://discord.com/api/v10'; -function getToken(account: string): string { +function getEnvToken(account: string): string { const envKey = `DISCORD_${account.toUpperCase()}_BOT_TOKEN`; return process.env[envKey] ?? ''; } +async function resolveToken(args: { account?: string }, customer?: Customer): Promise { + if (customer) { + const creds = await customer.getCredential('discord'); + if (!creds) throw new Error('Discord not connected for this account'); + return creds.accessToken; + } + const token = getEnvToken(args.account ?? 'default'); + if (!token) throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN'); + return token; +} + async function discordRequest( token: string, endpoint: string, @@ -30,72 +45,60 @@ async function discordRequest( return res.json(); } -export async function getMe(args: { account?: string }): Promise<{ - id: string; - username: string; - bot: boolean; -}> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN'); - } +export async function getMe( + args: { account?: string }, + customer?: Customer +): Promise<{ id: string; username: string; bot: boolean }> { + const token = await resolveToken(args, customer); return discordRequest(token, '/users/@me'); } -export async function getGuilds(args: { account?: string }): Promise> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN'); - } +export async function getGuilds( + args: { account?: string }, + customer?: Customer +): Promise> { + const token = await resolveToken(args, customer); return discordRequest(token, '/users/@me/guilds'); } -export async function getChannels(args: { guild_id: string; account?: string }): Promise> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN'); - } +export async function getChannels( + args: { guild_id: string; account?: string }, + customer?: Customer +): Promise> { + const token = await resolveToken(args, customer); return discordRequest(token, `/guilds/${args.guild_id}/channels`); } -export async function sendMessage(args: { - channel_id: string; - content: string; - account?: string; -}): Promise<{ id: string; channel_id: string }> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN'); - } +export async function sendMessage( + args: { channel_id: string; content: string; account?: string }, + customer?: Customer +): Promise<{ id: string; channel_id: string }> { + const audit = customer ? createToolAudit(customer.id, 'discord:sendMessage') : null; + const auditArgs = { channel_id: args.channel_id }; + const token = await resolveToken(args, customer); - return discordRequest(token, `/channels/${args.channel_id}/messages`, 'POST', { - content: args.content, - }); + try { + const result = await discordRequest(token, `/channels/${args.channel_id}/messages`, 'POST', { + content: args.content, + }); + if (audit) await audit.success(auditArgs); + return result; + } catch (err) { + if (audit) await audit.error(auditArgs, String(err)); + throw err; + } } -export async function getMessages(args: { - channel_id: string; - limit?: number; - account?: string; -}): Promise> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Discord credentials. Set DISCORD_{ACCOUNT}_BOT_TOKEN'); - } - + const token = await resolveToken(args, customer); const limit = args.limit ?? 10; return discordRequest(token, `/channels/${args.channel_id}/messages?limit=${limit}`); } diff --git a/src/clients/instagram.ts b/src/clients/instagram.ts index 8a8461b..d6ae542 100644 --- a/src/clients/instagram.ts +++ b/src/clients/instagram.ts @@ -1,15 +1,41 @@ +import type { Customer } from '../billing/middleware.js'; +import type { OAuthCredentials } from '../multitenancy/credential-store.js'; +import { createToolAudit } from '../multitenancy/audit-log.js'; + const INSTAGRAM_API_BASE = 'https://graph.facebook.com/v18.0'; -function getAccessToken(account: string): string { +interface InstagramCredentials extends OAuthCredentials { + businessAccountId: string; +} + +function getEnvToken(account: string): string { const envKey = `INSTAGRAM_${account.toUpperCase()}_ACCESS_TOKEN`; return process.env[envKey] ?? ''; } -function getBusinessAccountId(account: string): string { +function getEnvBusinessId(account: string): string { const envKey = `INSTAGRAM_${account.toUpperCase()}_BUSINESS_ACCOUNT_ID`; return process.env[envKey] ?? ''; } +async function resolveCreds( + args: { account?: string }, + customer?: Customer +): Promise<{ accessToken: string; businessAccountId: string }> { + if (customer) { + const creds = await customer.getCredential('instagram'); + if (!creds) throw new Error('Instagram not connected for this account'); + return { accessToken: creds.accessToken, businessAccountId: creds.businessAccountId }; + } + const account = args.account ?? 'default'; + const accessToken = getEnvToken(account); + const businessAccountId = getEnvBusinessId(account); + if (!accessToken || !businessAccountId) { + throw new Error('Missing Instagram credentials. Set INSTAGRAM_{ACCOUNT}_ACCESS_TOKEN and INSTAGRAM_{ACCOUNT}_BUSINESS_ACCOUNT_ID'); + } + return { accessToken, businessAccountId }; +} + async function instagramRequest( endpoint: string, accessToken: string, @@ -34,7 +60,10 @@ async function instagramRequest( return res.json(); } -export async function getProfile(args: { account?: string }): Promise<{ +export async function getProfile( + args: { account?: string }, + customer?: Customer +): Promise<{ id: string; username: string; name: string; @@ -42,12 +71,7 @@ export async function getProfile(args: { account?: string }): Promise<{ follows_count: number; media_count: number; }> { - const accessToken = getAccessToken(args.account ?? 'default'); - const businessAccountId = getBusinessAccountId(args.account ?? 'default'); - - if (!accessToken || !businessAccountId) { - throw new Error('Missing Instagram credentials. Set INSTAGRAM_{ACCOUNT}_ACCESS_TOKEN and INSTAGRAM_{ACCOUNT}_BUSINESS_ACCOUNT_ID'); - } + const { accessToken, businessAccountId } = await resolveCreds(args, customer); const data = await instagramRequest( `/${businessAccountId}?fields=username,name,followers_count,follows_count,media_count`, @@ -64,7 +88,10 @@ export async function getProfile(args: { account?: string }): Promise<{ }; } -export async function getMedia(args: { limit?: number; account?: string }): Promise> { - const accessToken = getAccessToken(args.account ?? 'default'); - const businessAccountId = getBusinessAccountId(args.account ?? 'default'); - - if (!accessToken || !businessAccountId) { - throw new Error('Missing Instagram credentials. Set INSTAGRAM_{ACCOUNT}_ACCESS_TOKEN and INSTAGRAM_{ACCOUNT}_BUSINESS_ACCOUNT_ID'); - } - + const { accessToken, businessAccountId } = await resolveCreds(args, customer); const limit = args.limit ?? 10; const data = await instagramRequest( `/${businessAccountId}/media?fields=id,caption,media_type,media_url,permalink,timestamp&limit=${limit}`, @@ -95,45 +116,37 @@ export async function getMedia(args: { limit?: number; account?: string }): Prom })); } -export async function createPost(args: { - image_url: string; - caption?: string; - account?: string; -}): Promise<{ success: boolean; media_id: string }> { - const accessToken = getAccessToken(args.account ?? 'default'); - const businessAccountId = getBusinessAccountId(args.account ?? 'default'); +export async function createPost( + args: { image_url: string; caption?: string; account?: string }, + customer?: Customer +): Promise<{ success: boolean; media_id: string }> { + const audit = customer ? createToolAudit(customer.id, 'instagram:createPost') : null; + const auditArgs = { image_url: args.image_url }; + const { accessToken, businessAccountId } = await resolveCreds(args, customer); - if (!accessToken || !businessAccountId) { - throw new Error('Missing Instagram credentials. Set INSTAGRAM_{ACCOUNT}_ACCESS_TOKEN and INSTAGRAM_{ACCOUNT}_BUSINESS_ACCOUNT_ID'); + try { + const container = await instagramRequest( + `/${businessAccountId}/media`, + accessToken, + 'POST', + { image_url: args.image_url, caption: args.caption, media_type: 'REELS' } + ); + + const creationId = container.id; + if (!creationId) throw new Error('Failed to create Instagram media container'); + + const publish = await instagramRequest( + `/${businessAccountId}/media_publish`, + accessToken, + 'POST', + { creation_id: creationId } + ); + + const result = { success: true, media_id: publish.id }; + if (audit) await audit.success(auditArgs); + return result; + } catch (err) { + if (audit) await audit.error(auditArgs, String(err)); + throw err; } - - // Step 1: Create media container - const container = await instagramRequest( - `/${businessAccountId}/media`, - accessToken, - 'POST', - { - image_url: args.image_url, - caption: args.caption, - media_type: 'REELS', - } - ); - - const creationId = container.id; - if (!creationId) { - throw new Error('Failed to create Instagram media container'); - } - - // Step 2: Publish the container - const publish = await instagramRequest( - `/${businessAccountId}/media_publish`, - accessToken, - 'POST', - { creation_id: creationId } - ); - - return { - success: true, - media_id: publish.id, - }; } diff --git a/src/clients/linkedin.ts b/src/clients/linkedin.ts index 1d33e5c..be017eb 100644 --- a/src/clients/linkedin.ts +++ b/src/clients/linkedin.ts @@ -1,12 +1,23 @@ +import type { Customer } from '../billing/middleware.js'; +import type { OAuthCredentials } from '../multitenancy/credential-store.js'; +import { createToolAudit } from '../multitenancy/audit-log.js'; + const LINKEDIN_API_BASE = 'https://api.linkedin.com/v2'; -function getEnvVar(account: string, key: string): string { - const envKey = `LINKEDIN_${account.toUpperCase()}_${key}`; +function getEnvToken(account: string): string { + const envKey = `LINKEDIN_${account.toUpperCase()}_ACCESS_TOKEN`; return process.env[envKey] ?? ''; } -function getAccessToken(account: string): string { - return getEnvVar(account, 'ACCESS_TOKEN'); +async function resolveToken(args: { account?: string }, customer?: Customer): Promise { + if (customer) { + const creds = await customer.getCredential('linkedin'); + if (!creds) throw new Error('LinkedIn not connected for this account'); + return creds.accessToken; + } + const token = getEnvToken(args.account ?? 'default'); + if (!token) throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN'); + return token; } async function linkedinRequest( @@ -35,23 +46,20 @@ async function linkedinRequest( return res.json(); } -export async function getProfile(args: { account?: string }): Promise<{ +export async function getProfile( + args: { account?: string }, + customer?: Customer +): Promise<{ id: string; firstName: string; lastName: string; email: string; picture?: string; }> { - const accessToken = getAccessToken(args.account ?? 'default'); - if (!accessToken) { - throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN'); - } + const accessToken = await resolveToken(args, customer); - // OpenID Connect userinfo endpoint (works with profile scope) const res = await fetch(`${LINKEDIN_API_BASE}/userinfo`, { - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, + headers: { 'Authorization': `Bearer ${accessToken}` }, signal: AbortSignal.timeout(15000), }); @@ -70,17 +78,15 @@ export async function getProfile(args: { account?: string }): Promise<{ }; } -export async function createPost(args: { - text: string; - visibility?: 'PUBLIC' | 'CONNECTIONS'; - account?: string; -}): Promise<{ success: boolean; post_id: string; url: string }> { - const accessToken = getAccessToken(args.account ?? 'default'); - if (!accessToken) { - throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN'); - } +export async function createPost( + args: { text: string; visibility?: 'PUBLIC' | 'CONNECTIONS'; account?: string }, + customer?: Customer +): Promise<{ success: boolean; post_id: string; url: string }> { + const audit = customer ? createToolAudit(customer.id, 'linkedin:createPost') : null; + const auditArgs = { text: args.text.slice(0, 100) }; + const accessToken = await resolveToken(args, customer); - const profile = await getProfile({ account: args.account }); + const profile = await getProfile(args, customer); const authorUrn = `urn:li:person:${profile.id}`; const body = { @@ -88,9 +94,7 @@ export async function createPost(args: { lifecycleState: 'PUBLISHED', specificContent: { 'com.linkedin.ugc.ShareContent': { - shareCommentary: { - text: args.text, - }, + shareCommentary: { text: args.text }, shareMediaCategory: 'NONE', }, }, @@ -99,24 +103,26 @@ export async function createPost(args: { }, }; - const data = await linkedinRequest('/ugcPosts', accessToken, 'POST', body); - const postId = data.id ?? ''; - return { - success: true, - post_id: postId, - url: postId ? `https://www.linkedin.com/feed/update/${postId}` : '', - }; + try { + const data = await linkedinRequest('/ugcPosts', accessToken, 'POST', body); + const postId = data.id ?? ''; + const result = { + success: true, + post_id: postId, + url: postId ? `https://www.linkedin.com/feed/update/${postId}` : '', + }; + if (audit) await audit.success(auditArgs); + return result; + } catch (err) { + if (audit) await audit.error(auditArgs, String(err)); + throw err; + } } -export async function searchConnections(args: { - keywords?: string; - account?: string; -}): Promise<{ message: string }> { - const accessToken = getAccessToken(args.account ?? 'default'); - if (!accessToken) { - throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN'); - } - +export async function searchConnections( + args: { keywords?: string; account?: string }, + _customer?: Customer +): Promise<{ message: string }> { throw new Error( 'LinkedIn connections search requires the LinkedIn Partnership Program. ' + 'Public API access to connections was removed. ' + @@ -124,16 +130,10 @@ export async function searchConnections(args: { ); } -export async function sendMessage(args: { - recipient_id: string; - message: string; - account?: string; -}): Promise<{ message: string }> { - const accessToken = getAccessToken(args.account ?? 'default'); - if (!accessToken) { - throw new Error('Missing LinkedIn credentials. Set LINKEDIN_{ACCOUNT}_ACCESS_TOKEN'); - } - +export async function sendMessage( + args: { recipient_id: string; message: string; account?: string }, + _customer?: Customer +): Promise<{ message: string }> { throw new Error( 'LinkedIn messaging requires the LinkedIn Partnership Program. ' + 'Direct messaging is not available through the public API. ' + diff --git a/src/clients/telegram.ts b/src/clients/telegram.ts index 62a138c..b5768d2 100644 --- a/src/clients/telegram.ts +++ b/src/clients/telegram.ts @@ -1,10 +1,25 @@ +import type { Customer } from '../billing/middleware.js'; +import type { OAuthCredentials } from '../multitenancy/credential-store.js'; +import { createToolAudit } from '../multitenancy/audit-log.js'; + const TELEGRAM_API_BASE = 'https://api.telegram.org'; -function getToken(account: string): string { +function getEnvToken(account: string): string { const envKey = `TELEGRAM_${account.toUpperCase()}_BOT_TOKEN`; return process.env[envKey] ?? ''; } +async function resolveToken(args: { account?: string }, customer?: Customer): Promise { + if (customer) { + const creds = await customer.getCredential('telegram'); + if (!creds) throw new Error('Telegram not connected for this account'); + return creds.accessToken; + } + const token = getEnvToken(args.account ?? 'default'); + if (!token) throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); + return token; +} + async function telegramRequest( token: string, method: string, @@ -25,69 +40,69 @@ async function telegramRequest( return data.result; } -export async function getMe(args: { account?: string }): Promise<{ +export async function getMe( + args: { account?: string }, + customer?: Customer +): Promise<{ id: number; first_name: string; username: string; is_bot: boolean; }> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); - } + const token = await resolveToken(args, customer); return telegramRequest(token, 'getMe'); } -export async function sendMessage(args: { - chat_id: string | number; - text: string; - parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2'; - account?: string; -}): Promise<{ message_id: number; chat_id: string | number }> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); +export async function sendMessage( + args: { chat_id: string | number; text: string; parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2'; account?: string }, + customer?: Customer +): Promise<{ message_id: number; chat_id: string | number }> { + const audit = customer ? createToolAudit(customer.id, 'telegram:sendMessage') : null; + const auditArgs = { chat_id: args.chat_id }; + const token = await resolveToken(args, customer); + + try { + const result = await telegramRequest(token, 'sendMessage', { + chat_id: args.chat_id, + text: args.text, + parse_mode: args.parse_mode, + }); + const r = { message_id: result.message_id, chat_id: result.chat.id }; + if (audit) await audit.success(auditArgs); + return r; + } catch (err) { + if (audit) await audit.error(auditArgs, String(err)); + throw err; } - - const result = await telegramRequest(token, 'sendMessage', { - chat_id: args.chat_id, - text: args.text, - parse_mode: args.parse_mode, - }); - - return { - message_id: result.message_id, - chat_id: result.chat.id, - }; } -export async function sendPhoto(args: { - chat_id: string | number; - photo: string; - caption?: string; - account?: string; -}): Promise<{ message_id: number; chat_id: string | number }> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); +export async function sendPhoto( + args: { chat_id: string | number; photo: string; caption?: string; account?: string }, + customer?: Customer +): Promise<{ message_id: number; chat_id: string | number }> { + const audit = customer ? createToolAudit(customer.id, 'telegram:sendPhoto') : null; + const auditArgs = { chat_id: args.chat_id }; + const token = await resolveToken(args, customer); + + try { + const result = await telegramRequest(token, 'sendPhoto', { + chat_id: args.chat_id, + photo: args.photo, + caption: args.caption, + }); + const r = { message_id: result.message_id, chat_id: result.chat.id }; + if (audit) await audit.success(auditArgs); + return r; + } catch (err) { + if (audit) await audit.error(auditArgs, String(err)); + throw err; } - - const result = await telegramRequest(token, 'sendPhoto', { - chat_id: args.chat_id, - photo: args.photo, - caption: args.caption, - }); - - return { - message_id: result.message_id, - chat_id: result.chat.id, - }; } -export async function getUpdates(args: { - limit?: number; - account?: string; -}): Promise> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); - } - - return telegramRequest(token, 'getUpdates', { - limit: args.limit ?? 10, - }); + const token = await resolveToken(args, customer); + return telegramRequest(token, 'getUpdates', { limit: args.limit ?? 10 }); } -export async function getChat(args: { - chat_id: string | number; - account?: string; -}): Promise<{ +export async function getChat( + args: { chat_id: string | number; account?: string }, + customer?: Customer +): Promise<{ id: number; type: string; title?: string; @@ -118,12 +127,6 @@ export async function getChat(args: { description?: string; member_count?: number; }> { - const token = getToken(args.account ?? 'default'); - if (!token) { - throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); - } - - return telegramRequest(token, 'getChat', { - chat_id: args.chat_id, - }); + const token = await resolveToken(args, customer); + return telegramRequest(token, 'getChat', { chat_id: args.chat_id }); } diff --git a/src/clients/twitter.ts b/src/clients/twitter.ts index b9be3b0..edf9210 100644 --- a/src/clients/twitter.ts +++ b/src/clients/twitter.ts @@ -1,10 +1,24 @@ +import type { Customer } from '../billing/middleware.js'; +import type { OAuthCredentials } from '../multitenancy/credential-store.js'; + const TWITTER_API_BASE = 'https://api.twitter.com/2'; -function getBearerToken(account: string): string { +function getEnvToken(account: string): string { const envKey = `TWITTER_${account.toUpperCase()}_BEARER_TOKEN`; return process.env[envKey] ?? ''; } +async function resolveToken(args: { account?: string }, customer?: Customer): Promise { + if (customer) { + const creds = await customer.getCredential('twitter'); + if (!creds) throw new Error('Twitter not connected for this account'); + return creds.accessToken; + } + const token = getEnvToken(args.account ?? 'default'); + if (!token) throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN'); + return token; +} + async function twitterRequest( endpoint: string, bearerToken: string, @@ -18,9 +32,7 @@ async function twitterRequest( } const res = await fetch(url.toString(), { - headers: { - 'Authorization': `Bearer ${bearerToken}`, - }, + headers: { 'Authorization': `Bearer ${bearerToken}` }, signal: AbortSignal.timeout(15000), }); @@ -32,20 +44,11 @@ async function twitterRequest( return res.json(); } -export async function searchTweets(args: { - query: string; - max_results?: number; - account?: string; -}): Promise> { - const bearerToken = getBearerToken(args.account ?? 'default'); - if (!bearerToken) { - throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN'); - } +export async function searchTweets( + args: { query: string; max_results?: number; account?: string }, + customer?: Customer +): Promise> { + const bearerToken = await resolveToken(args, customer); const data = await twitterRequest('/tweets/search/recent', bearerToken, { query: args.query, @@ -56,10 +59,10 @@ export async function searchTweets(args: { return (data.data ?? []) as Array<{ id: string; text: string; author_id?: string; created_at?: string }>; } -export async function getUserProfile(args: { - username: string; - account?: string; -}): Promise<{ +export async function getUserProfile( + args: { username: string; account?: string }, + customer?: Customer +): Promise<{ id: string; name: string; username: string; @@ -68,10 +71,7 @@ export async function getUserProfile(args: { following_count?: number; tweet_count?: number; }> { - const bearerToken = getBearerToken(args.account ?? 'default'); - if (!bearerToken) { - throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN'); - } + const bearerToken = await resolveToken(args, customer); const data = await twitterRequest(`/users/by/username/${args.username}`, bearerToken, { 'user.fields': 'description,public_metrics', @@ -90,26 +90,15 @@ export async function getUserProfile(args: { }; } -export async function getUserTweets(args: { - username: string; - max_results?: number; - account?: string; -}): Promise> { - const bearerToken = getBearerToken(args.account ?? 'default'); - if (!bearerToken) { - throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN'); - } +export async function getUserTweets( + args: { username: string; max_results?: number; account?: string }, + customer?: Customer +): Promise> { + const bearerToken = await resolveToken(args, customer); - // First get user ID const userData = await twitterRequest(`/users/by/username/${args.username}`, bearerToken); const userId = userData.data?.id; - if (!userId) { - throw new Error(`User @${args.username} not found`); - } + if (!userId) throw new Error(`User @${args.username} not found`); const data = await twitterRequest(`/users/${userId}/tweets`, bearerToken, { max_results: String(Math.min(args.max_results ?? 10, 100)), @@ -119,15 +108,10 @@ export async function getUserTweets(args: { return (data.data ?? []) as Array<{ id: string; text: string; created_at?: string }>; } -export async function createTweet(args: { - text: string; - account?: string; -}): Promise<{ message: string }> { - const bearerToken = getBearerToken(args.account ?? 'default'); - if (!bearerToken) { - throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN'); - } - +export async function createTweet( + args: { text: string; account?: string }, + _customer?: Customer +): Promise<{ message: string }> { throw new Error( 'Twitter/X posting requires a paid API tier. ' + 'The free tier is read-only (500 tweets/month). ' + diff --git a/src/clients/whatsapp.ts b/src/clients/whatsapp.ts index 081ce72..18b9f43 100644 --- a/src/clients/whatsapp.ts +++ b/src/clients/whatsapp.ts @@ -1,23 +1,25 @@ - +import type { Customer } from '../billing/middleware.js'; +import type { WhatsAppCredentials } from '../multitenancy/credential-store.js'; +import { createToolAudit } from '../multitenancy/audit-log.js'; const WHATSAPP_API_VERSION = 'v18.0'; const WHATSAPP_BASE_URL = process.env['WHATSAPP_API_BASE_URL'] ?? 'https://graph.facebook.com'; -function getEnvVar(prefix: string, account: string, key: string): string { +function getEnvVar(account: string, key: string): string { const envKey = `WHATSAPP_${account.toUpperCase()}_${key}`; return process.env[envKey] ?? ''; } function getPhoneNumberId(account: string): string { - return getEnvVar('WHATSAPP', account, 'PHONE_NUMBER_ID'); + return getEnvVar(account, 'PHONE_NUMBER_ID'); } function getAccessToken(account: string): string { - return getEnvVar('WHATSAPP', account, 'ACCESS_TOKEN'); + return getEnvVar(account, 'ACCESS_TOKEN'); } function getBusinessAccountId(account: string): string { - return getEnvVar('WHATSAPP', account, 'BUSINESS_ACCOUNT_ID'); + return getEnvVar(account, 'BUSINESS_ACCOUNT_ID'); } interface WhatsAppMessageResponse { @@ -52,13 +54,37 @@ async function whatsappApiRequest( return res.json(); } -export async function sendMessage(args: { to: string; message: string; account?: string }): Promise<{ success: boolean; message_id: string }> { - const phoneId = getPhoneNumberId(args.account ?? 'default'); - const accessToken = getAccessToken(args.account ?? 'default'); - +async function resolveWhatsAppCreds( + args: { account?: string }, + customer?: Customer +): Promise<{ phoneId: string; accessToken: string; businessAccountId: string }> { + if (customer) { + const creds = await customer.getCredential('whatsapp'); + if (!creds) throw new Error('WhatsApp not connected for this account'); + return { + phoneId: creds.phoneNumberId, + accessToken: creds.accessToken, + businessAccountId: creds.businessAccountId, + }; + } + const account = args.account ?? 'default'; + const phoneId = getPhoneNumberId(account); + const accessToken = getAccessToken(account); + const businessAccountId = getBusinessAccountId(account); if (!phoneId || !accessToken) { throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN'); } + return { phoneId, accessToken, businessAccountId }; +} + +export async function sendMessage( + args: { to: string; message: string; account?: string }, + customer?: Customer +): Promise<{ success: boolean; message_id: string }> { + const audit = customer ? createToolAudit(customer.id, 'whatsapp:sendMessage') : null; + const auditArgs = { to: args.to }; + + const { phoneId, accessToken } = await resolveWhatsAppCreds(args, customer); const body = { messaging_product: 'whatsapp', @@ -67,18 +93,26 @@ export async function sendMessage(args: { to: string; message: string; account?: text: { body: args.message }, }; - const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body); - const response = data as WhatsAppMessageResponse; - return { success: true, message_id: response.messages?.[0]?.id ?? '' }; + try { + const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body); + const response = data as WhatsAppMessageResponse; + const result = { success: true, message_id: response.messages?.[0]?.id ?? '' }; + if (audit) await audit.success(auditArgs); + return result; + } catch (err) { + if (audit) await audit.error(auditArgs, String(err)); + throw err; + } } -export async function sendTemplate(args: { to: string; template_name: string; language?: string; components?: unknown[]; account?: string }): Promise<{ success: boolean; message_id: string }> { - const phoneId = getPhoneNumberId(args.account ?? 'default'); - const accessToken = getAccessToken(args.account ?? 'default'); +export async function sendTemplate( + args: { to: string; template_name: string; language?: string; components?: unknown[]; account?: string }, + customer?: Customer +): Promise<{ success: boolean; message_id: string }> { + const audit = customer ? createToolAudit(customer.id, 'whatsapp:sendTemplate') : null; + const auditArgs = { to: args.to, template_name: args.template_name }; - if (!phoneId || !accessToken) { - throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN'); - } + const { phoneId, accessToken } = await resolveWhatsAppCreds(args, customer); const body: Record = { messaging_product: 'whatsapp', @@ -94,31 +128,35 @@ export async function sendTemplate(args: { to: string; template_name: string; la (body.template as Record).components = args.components; } - const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body); - const response = data as WhatsAppMessageResponse; - return { success: true, message_id: response.messages?.[0]?.id ?? '' }; + try { + const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body); + const response = data as WhatsAppMessageResponse; + const result = { success: true, message_id: response.messages?.[0]?.id ?? '' }; + if (audit) await audit.success(auditArgs); + return result; + } catch (err) { + if (audit) await audit.error(auditArgs, String(err)); + throw err; + } } -export async function getMessageStatus(args: { message_id: string; account?: string }): Promise<{ message_id: string; status: string; timestamp?: string }> { - const phoneId = getPhoneNumberId(args.account ?? 'default'); - const accessToken = getAccessToken(args.account ?? 'default'); - - if (!phoneId || !accessToken) { - throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_PHONE_NUMBER_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN'); - } - - // Note: Meta Cloud API doesn't support polling message status via GET +export async function getMessageStatus( + args: { message_id: string; account?: string }, + _customer?: Customer +): Promise<{ message_id: string; status: string; timestamp?: string }> { + // Meta Cloud API doesn't support polling message status via GET // Status updates are only available via webhooks (push-based) throw new Error('whatsapp_get_message_status is not supported. Meta Cloud API only provides delivery status via webhooks. Use POST /api/whatsapp/webhook to receive status updates.'); } -export async function listTemplates(args: { account?: string }): Promise<{ templates: Array<{ name: string; language: string; status: string }> }> { - const account = args.account ?? 'default'; - const businessAccountId = getBusinessAccountId(account); - const accessToken = getAccessToken(account); +export async function listTemplates( + args: { account?: string }, + customer?: Customer +): Promise<{ templates: Array<{ name: string; language: string; status: string }> }> { + const { businessAccountId, accessToken } = await resolveWhatsAppCreds(args, customer); - if (!businessAccountId || !accessToken) { - throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_BUSINESS_ACCOUNT_ID and WHATSAPP_{ACCOUNT}_ACCESS_TOKEN'); + if (!businessAccountId) { + throw new Error('Missing WhatsApp credentials. Set WHATSAPP_{ACCOUNT}_BUSINESS_ACCOUNT_ID'); } const url = `${WHATSAPP_BASE_URL}/${WHATSAPP_API_VERSION}/${businessAccountId}/message_templates?fields=name,language,status`; diff --git a/src/db.ts b/src/db.ts index 90dde8a..f6a57d7 100644 --- a/src/db.ts +++ b/src/db.ts @@ -73,6 +73,18 @@ export async function initDatabase(): Promise { INDEX idx_expires (expires_at) ) `); + + await db.execute(` + CREATE TABLE IF NOT EXISTS customers ( + id VARCHAR(255) PRIMARY KEY, + api_key VARCHAR(255) NOT NULL UNIQUE, + plan ENUM('free', 'starter', 'growth', 'enterprise') DEFAULT 'free', + active BOOLEAN DEFAULT TRUE, + email VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_api_key (api_key) + ) + `); } finally { db.release(); } diff --git a/src/index.ts b/src/index.ts index 6b21d42..746d14d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,9 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { tools, handleToolCall } from './tools.js'; import { getManifest, getOpenApiSpec } from './manifest.js'; +import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js'; +import { storeCredential, type Platform } from './multitenancy/credential-store.js'; +import { meterMiddleware, type Customer } from './billing/middleware.js'; import { registerClient, getClient, @@ -580,6 +583,121 @@ app.get('/api/whatsapp/templates', requireAuth, async (req, res) => { } }); +// ── WhatsApp webhook (multi-tenant) ───────────────────────────── +async function handleInboundWhatsAppMessage(event: RoutedWebhookEvent): Promise { + console.log(`[webhook/whatsapp] inbound message from=${event.message.from} customer=${event.customerId} type=${event.message.type}`); + // Future: route to customer's agent or queue for processing +} + +// WhatsApp webhook verification (GET) +app.get('/webhook/whatsapp', (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + if (mode === 'subscribe' && token === process.env.WA_VERIFY_TOKEN) { + res.status(200).send(challenge); + } else { + res.status(403).send('Forbidden'); + } +}); + +// WhatsApp webhook delivery (POST) — multi-tenant routed +app.post('/webhook/whatsapp', express.json(), async (req, res) => { + // Always acknowledge immediately to prevent Meta retries (20s window) + res.status(200).send('EVENT_RECEIVED'); + + try { + const events = await routeWhatsAppWebhook(req.body as Record); + for (const event of events) { + await handleInboundWhatsAppMessage(event); + } + } catch (err) { + console.error('[webhook/whatsapp] routing error:', err); + } +}); + +// ── Customer onboarding endpoints ─────────────────────────────── + +// Connect WhatsApp — called after customer enters their Meta credentials +app.post('/api/connect/whatsapp', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const { phoneNumberId, accessToken, businessAccountId } = req.body as Record; + + if (!phoneNumberId || !accessToken || !businessAccountId) { + res.status(400).json({ error: 'missing_fields' }); + return; + } + + await storeCredential(customer.id, 'whatsapp', { phoneNumberId, accessToken, businessAccountId }); + await registerWhatsAppNumber(customer.id, phoneNumberId); + + res.json({ connected: true, platform: 'whatsapp' }); +}); + +// Connect email (IMAP/SMTP) +app.post('/api/connect/email', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const { host, port, user, password, smtpHost, smtpPort } = req.body as Record; + + if (!host || !port || !user || !password) { + res.status(400).json({ error: 'missing_fields' }); + return; + } + + await storeCredential(customer.id, 'email', { + host, + port: parseInt(port, 10), + user, + password, + smtpHost, + smtpPort: smtpPort ? parseInt(smtpPort, 10) : undefined, + }); + + res.json({ connected: true, platform: 'email' }); +}); + +// Connect OAuth platforms (LinkedIn, Telegram, Discord, Instagram, Twitter) +app.post('/api/connect/:platform', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const platform = req.params.platform as Platform; + const { accessToken, refreshToken, expiresAt, scope } = req.body as Record; + + const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter']; + if (!validPlatforms.includes(platform)) { + res.status(400).json({ error: 'unknown_platform' }); + return; + } + + if (!accessToken) { + res.status(400).json({ error: 'missing_fields' }); + return; + } + + await storeCredential(customer.id, platform, { + accessToken, + refreshToken, + expiresAt: expiresAt ? parseInt(expiresAt, 10) : undefined, + scope, + }); + + res.json({ connected: true, platform }); +}); + +// Get connection status for a customer +app.get('/api/connections', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'obsidian']; + + const status: Record = {}; + for (const platform of platforms) { + const cred = await customer.getCredential(platform); + status[platform] = cred !== null; + } + + res.json({ customerId: customer.id, connections: status }); +}); + // ── LinkedIn REST endpoints ───────────────────────────────────── app.get('/api/linkedin/profile', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; diff --git a/src/manifest.ts b/src/manifest.ts index 374d30e..4f885a1 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -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")' }, }, }, diff --git a/src/multitenancy/audit-log.ts b/src/multitenancy/audit-log.ts new file mode 100644 index 0000000..15ef31a --- /dev/null +++ b/src/multitenancy/audit-log.ts @@ -0,0 +1,81 @@ +import { createClient } from 'redis'; + +const redis = createClient({ url: process.env.REDIS_URL }); +redis.connect().catch((err) => console.error('[audit-log] Redis connect error:', err)); + +export interface AuditEntry { + customerId: string; + tool: string; + args: Record; + result: 'success' | 'error'; + errorMessage?: string; + durationMs: number; + timestamp: string; +} + +// Retain 90 days of logs per customer. Key: logs:{customerId}:{ISO-date}:{seq} +export async function logToolCall(entry: AuditEntry): Promise { + const date = entry.timestamp.slice(0, 10); // YYYY-MM-DD + const seqKey = `log_seq:${entry.customerId}:${date}`; + const seq = await redis.incr(seqKey); + // Expire the sequence key after 95 days so it cleans up + if (seq === 1) await redis.expire(seqKey, 60 * 60 * 24 * 95); + + const logKey = `logs:${entry.customerId}:${date}:${String(seq).padStart(8, '0')}`; + await redis.set(logKey, JSON.stringify(entry), { EX: 60 * 60 * 24 * 90 }); +} + +// Retrieve logs for one customer for a date range — customerId is REQUIRED +export async function getCustomerLogs( + customerId: string, + fromDate: string, // YYYY-MM-DD + toDate: string // YYYY-MM-DD +): Promise { + const dates = getDatesInRange(fromDate, toDate); + const entries: AuditEntry[] = []; + + for (const date of dates) { + const pattern = `logs:${customerId}:${date}:*`; + const keys = await redis.keys(pattern); + for (const key of keys) { + const raw = await redis.get(key); + if (raw) entries.push(JSON.parse(raw) as AuditEntry); + } + } + + return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); +} + +function getDatesInRange(from: string, to: string): string[] { + const dates: string[] = []; + const current = new Date(from); + const end = new Date(to); + while (current <= end) { + dates.push(current.toISOString().slice(0, 10)); + current.setDate(current.getDate() + 1); + } + return dates; +} + +// Convenience wrapper for tool handlers — call before and after +export function createToolAudit(customerId: string, tool: string) { + const start = Date.now(); + return { + success: async (args: Record) => { + await logToolCall({ + customerId, tool, args, + result: 'success', + durationMs: Date.now() - start, + timestamp: new Date().toISOString(), + }); + }, + error: async (args: Record, errorMessage: string) => { + await logToolCall({ + customerId, tool, args, + result: 'error', errorMessage, + durationMs: Date.now() - start, + timestamp: new Date().toISOString(), + }); + }, + }; +} diff --git a/src/multitenancy/credential-store.ts b/src/multitenancy/credential-store.ts new file mode 100644 index 0000000..3392b1a --- /dev/null +++ b/src/multitenancy/credential-store.ts @@ -0,0 +1,84 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; +import { createClient } from 'redis'; + +const redis = createClient({ url: process.env.REDIS_URL }); +redis.connect().catch((err) => console.error('[credential-store] Redis connect error:', err)); + +const ENCRYPTION_KEY = Buffer.from(process.env.CREDENTIAL_ENCRYPTION_KEY ?? '0'.repeat(64), 'hex'); +// CREDENTIAL_ENCRYPTION_KEY must be a 64-char hex string (32 bytes) +// Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + +function encrypt(plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`; +} + +function decrypt(ciphertext: string): string { + const [ivHex, tagHex, encHex] = ciphertext.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); + const encrypted = Buffer.from(encHex, 'hex'); + const decipher = createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + decipher.setAuthTag(tag); + return decipher.update(encrypted) + decipher.final('utf8'); +} + +export type Platform = 'email' | 'whatsapp' | 'linkedin' | 'telegram' | 'discord' | 'instagram' | 'twitter' | 'obsidian'; + +export interface EmailCredentials { + host: string; + port: number; + user: string; + password: string; + smtpHost?: string; + smtpPort?: number; +} + +export interface OAuthCredentials { + accessToken: string; + refreshToken?: string; + expiresAt?: number; + scope?: string; +} + +export interface WhatsAppCredentials { + phoneNumberId: string; + accessToken: string; + businessAccountId: string; +} + +export type PlatformCredentials = EmailCredentials | OAuthCredentials | WhatsAppCredentials | Record; + +export async function storeCredential( + customerId: string, + platform: Platform, + credentials: PlatformCredentials +): Promise { + const key = `creds:${customerId}:${platform}`; + const encrypted = encrypt(JSON.stringify(credentials)); + // Credentials don't expire — they persist until explicitly revoked + await redis.set(key, encrypted); +} + +export async function getCredential( + customerId: string, + platform: Platform +): Promise { + const key = `creds:${customerId}:${platform}`; + const encrypted = await redis.get(key); + if (!encrypted) return null; + return JSON.parse(decrypt(encrypted)) as T; +} + +export async function revokeCredential(customerId: string, platform: Platform): Promise { + await redis.del(`creds:${customerId}:${platform}`); +} + +export async function revokeAllCredentials(customerId: string): Promise { + const pattern = `creds:${customerId}:*`; + const keys = await redis.keys(pattern); + if (keys.length > 0) await redis.del(keys); +} diff --git a/src/multitenancy/webhook-router.ts b/src/multitenancy/webhook-router.ts new file mode 100644 index 0000000..a716849 --- /dev/null +++ b/src/multitenancy/webhook-router.ts @@ -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 { + await redis.set(`wa_phone_id:${phoneNumberId}`, customerId); +} + +export async function unregisterWhatsAppNumber(phoneNumberId: string): Promise { + await redis.del(`wa_phone_id:${phoneNumberId}`); +} + +export async function resolveCustomerFromPhoneNumberId( + phoneNumberId: string +): Promise { + 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 +): Promise { + 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(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; +} diff --git a/src/tools.ts b/src/tools.ts index 9a30505..437e82b 100644 --- a/src/tools.ts +++ b/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): Account { export async function handleToolCall( name: string, - args: Record + args: Record, + 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