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:
@@ -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<InstagramCredentials>('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<Array<{
|
||||
export async function getMedia(
|
||||
args: { limit?: number; account?: string },
|
||||
customer?: Customer
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
caption?: string;
|
||||
media_type: string;
|
||||
@@ -72,13 +99,7 @@ export async function getMedia(args: { limit?: number; account?: string }): Prom
|
||||
permalink?: string;
|
||||
timestamp?: string;
|
||||
}>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user