feat: multi-tenant credential isolation + architecture docs

- Add src/multitenancy/ with AES-256-GCM credential store, WhatsApp
  webhook router (phone_number_id -> customerId), and per-customer
  audit log (90-day Redis TTL)
- Add src/billing/ with plan definitions and meterMiddleware that
  resolves API key -> Customer object with getCredential() closure
- Refactor all src/clients/* to accept optional customer param,
  falling back to env vars for backward compat with single-user mode
- Thread customer through handleToolCall(name, args, customer?)
- Add customers table to MySQL schema initDatabase()
- Add /webhook/whatsapp (immediate 200 + async routing) and
  /api/connect/* onboarding endpoints to index.ts
- Add Redis 7 to docker-compose.yml; add REDIS_URL and
  CREDENTIAL_ENCRYPTION_KEY to hermes-k8s.yaml
- Add product/incubation/ with architecture write-up and PlantUML
  diagrams (system architecture + 5 user flows)
- Extend OpenAPI spec in manifest.ts with all platform endpoints

Verification: 3 isolation tests (credential, webhook routing, audit
log) passed against live Redis. Deployed to hermes.squaremcp.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-08 11:27:29 -04:00
parent 59501f11f1
commit 8d62e4d9d5
21 changed files with 1863 additions and 346 deletions

View File

@@ -0,0 +1,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<string, unknown>;
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<void> {
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<AuditEntry[]> {
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<string, unknown>) => {
await logToolCall({
customerId, tool, args,
result: 'success',
durationMs: Date.now() - start,
timestamp: new Date().toISOString(),
});
},
error: async (args: Record<string, unknown>, errorMessage: string) => {
await logToolCall({
customerId, tool, args,
result: 'error', errorMessage,
durationMs: Date.now() - start,
timestamp: new Date().toISOString(),
});
},
};
}

View File

@@ -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<string, string>;
export async function storeCredential(
customerId: string,
platform: Platform,
credentials: PlatformCredentials
): Promise<void> {
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<T extends PlatformCredentials>(
customerId: string,
platform: Platform
): Promise<T | null> {
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<void> {
await redis.del(`creds:${customerId}:${platform}`);
}
export async function revokeAllCredentials(customerId: string): Promise<void> {
const pattern = `creds:${customerId}:*`;
const keys = await redis.keys(pattern);
if (keys.length > 0) await redis.del(keys);
}

View File

@@ -0,0 +1,104 @@
import { createClient } from 'redis';
import { getCredential, WhatsAppCredentials } from './credential-store.js';
const redis = createClient({ url: process.env.REDIS_URL });
redis.connect().catch((err) => console.error('[webhook-router] Redis connect error:', err));
// Call this at customer onboarding when they connect their WhatsApp Business number
export async function registerWhatsAppNumber(
customerId: string,
phoneNumberId: string
): Promise<void> {
await redis.set(`wa_phone_id:${phoneNumberId}`, customerId);
}
export async function unregisterWhatsAppNumber(phoneNumberId: string): Promise<void> {
await redis.del(`wa_phone_id:${phoneNumberId}`);
}
export async function resolveCustomerFromPhoneNumberId(
phoneNumberId: string
): Promise<string | null> {
return redis.get(`wa_phone_id:${phoneNumberId}`);
}
// WhatsApp Cloud API webhook payload types
interface WhatsAppWebhookEntry {
id: string;
changes: Array<{
value: {
messaging_product: string;
metadata: {
display_phone_number: string;
phone_number_id: string;
};
messages?: WhatsAppInboundMessage[];
statuses?: WhatsAppStatus[];
};
field: string;
}>;
}
export interface WhatsAppInboundMessage {
from: string;
id: string;
timestamp: string;
text?: { body: string };
type: string;
}
interface WhatsAppStatus {
id: string;
status: string;
timestamp: string;
recipient_id: string;
}
export interface RoutedWebhookEvent {
customerId: string;
phoneNumberId: string;
message: WhatsAppInboundMessage;
credentials: WhatsAppCredentials;
}
// Parse the incoming webhook and return one routed event per message
export async function routeWhatsAppWebhook(
body: Record<string, unknown>
): Promise<RoutedWebhookEvent[]> {
const events: RoutedWebhookEvent[] = [];
if (body.object !== 'whatsapp_business_account') return events;
const entries = (body.entry as WhatsAppWebhookEntry[]) ?? [];
for (const entry of entries) {
for (const change of entry.changes) {
if (change.field !== 'messages') continue;
const { phone_number_id } = change.value.metadata;
const messages = change.value.messages ?? [];
if (messages.length === 0) continue;
// Resolve which customer owns this phone number
const customerId = await resolveCustomerFromPhoneNumberId(phone_number_id);
if (!customerId) {
console.warn(`[webhook-router] Unroutable WhatsApp message to phone_number_id=${phone_number_id}`);
continue;
}
// Load that customer's WhatsApp credentials
const credentials = await getCredential<WhatsAppCredentials>(customerId, 'whatsapp');
if (!credentials) {
console.error(`[webhook-router] No WhatsApp credentials for customerId=${customerId}`);
continue;
}
for (const message of messages) {
events.push({ customerId, phoneNumberId: phone_number_id, message, credentials });
}
}
}
return events;
}