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

92
src/billing/middleware.ts Normal file
View File

@@ -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: <T extends PlatformCredentials>(platform: Platform) => Promise<T | null>;
}
interface CustomerRow extends RowDataPacket {
id: string;
plan: PlanKey;
active: boolean;
email: string;
}
async function resolveCustomer(apiKey: string): Promise<Customer | null> {
const cached = await redis.get(`customer:apikey:${apiKey}`);
if (cached) {
const base = JSON.parse(cached) as Omit<Customer, 'getCredential'>;
// Re-attach the credential loader (functions can't be cached)
return {
...base,
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
getCredential<T>(base.id, platform),
};
}
const [rows] = await getPool().query<CustomerRow[]>(
'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: <T extends PlatformCredentials>(platform: Platform) =>
getCredential<T>(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<void> {
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);
}
}

30
src/billing/plans.ts Normal file
View File

@@ -0,0 +1,30 @@
export type PlanKey = 'free' | 'starter' | 'growth' | 'enterprise';
export interface Plan {
name: string;
monthlyCallLimit: number;
platforms: string[];
}
export const PLANS: Record<PlanKey, Plan> = {
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'],
},
};