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:
92
src/billing/middleware.ts
Normal file
92
src/billing/middleware.ts
Normal 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
30
src/billing/plans.ts
Normal 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'],
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user